sourceafMorphia::Datastore.fan

using afBeanUtils::ReflectUtils
using afMongo::MongoConnMgr
using afMongo::MongoColl
using afMongo::MongoCmd
using afMongo::MongoDb
using afMongo::MongoQ

** (Service) -
** Wraps a MongoDB [Collection]`afMongo::MongoColl`, converting Fantom entities to / from BSON documents.
**
** When injecting as a service, use the '@Inject.type' attribute to state the Entity type:
**
**   syntax: fantom
**
**   @Inject { type=MyEntity# }
**   private const Datastore myEntityDatastore
**
** You can also autobuild a Datastore instance by passing in the entity type as a ctor param:
**
**   syntax: fantom
**
**   scope.build(Datastore#, [MyEntity#])
const mixin Datastore {
    
    ** The backing connector manager instance.
    abstract MongoConnMgr connMgr()
    
    ** Creates an 'MongoDb' instance of the associated DB.
    abstract MongoDb db()
    
    ** The underlying MongoDB collection this Datastore wraps.
    abstract MongoColl collection()

    ** The Fantom entity type this Datastore associates with.
    abstract Type type()

    ** The name of the associated Mongo Collection.
    abstract Str name()

    ** Create a new Datastore instance.
    static new make(Type entityType, MongoConnMgr connMgr, BsonConvs? bsonConvs := null, Str? dbName := null) {
        DatastoreImpl(entityType, connMgr, bsonConvs, dbName)
    }

    

    // ---- Collection ----------------------------------------------------------------------------

    ** Returns 'true' if the underlying MongoDB collection exists.
    abstract Bool exists()

    ** Returns 'true' if the collection has no documents.
    **
    ** Convenience for 'datastore.exists && datastore.size == 0'.
    abstract Bool isEmpty()

    ** Drops the underlying MongoDB collection.
    **
    ** Note that deleting all documents is MUCH quicker than dropping the Collection.
    abstract This drop(Bool force := false)
    
    
    
    // ---- Cursor Queries ------------------------------------------------------------------------

    ** A general purpose 'find()' method whose cursor returns converted entity objects.
    ** 
    ** pre>
    ** syntax: fantom
    ** find(["rick":"morty"]) {
    **   it->sort        = ["fieldName":1]
    **   it->hint        = "_indexName_"
    **   it->skip        = 50
    **   it->limit       = 100
    **   it->projection  = ["_id":1, "name":1]
    **   it->batchSize   = 101
    **   it->singleBatch = true
    **   it->collation   = [...]
    ** }.toList
    ** <pre
    ** 
    ** The given query may generated from 'query()'.
    abstract MorphiaCur find([Str:Obj?]? query := null, |MongoCmd cmd|? optsFn := null)

    ** An (optimised) method to return one document from the given 'query'.
    **
    ** Throws an 'Err' if no documents are found and 'checked' is 'true'.
    ** Always throws an 'Err' if the query returns more than one document.
    abstract Obj? findOne(Bool checked, |MongoQ| queryFn)

    ** Returns a list of entities that match the given 'query'.
    ** 
    ** Use 'find()' if you need to set any options, like 'limit' or 'skip'.
    **
    ** 'sort' may one of:
    **  - 'Str'      - the name an index to be used as a hint
    **  - 'Str:Obj?' - a ordered sort document of field names with the standard Mongo '1' and '-1' values for ascending / descending
    **  - 'Field'    - the field to use for an (ascending) sort, use 'reverse()' on the returned list for descending sorts
    abstract Obj[] findAll(Obj? sort := null, |MongoQ|? queryFn := null)

    ** Returns the number of documents that would be returned by the given 'query'.
    abstract Int count(|MongoQ|? queryFn := null)

    ** Returns the document with the given Id.
    ** Convenience / shorthand notation for 'findOne(["_id": id], checked)'
    @Operator
    abstract Obj? get(Obj? id, Bool checked := true)
    
    

    // ---- Write Operations ----------------------------------------------------------------------

    ** Inserts the given entity.
    ** Returns the entity.
    abstract Obj insert(Obj entity)

    ** Deletes the given entity from the MongoDB.
    ** Throws an 'Err' if 'checked' and nothing was deleted.
    abstract Void delete(Obj entity, Bool checked := true)

    ** Deletes entity with the given Id.
    ** Throws an 'Err' if 'checked' and nothing was deleted.
    abstract Void deleteById(Obj id, Bool checked := true)

    ** Deletes all entities in the Datastore. Returns the number of entities deleted.
    ** 
    ** Note this is MUCH quicker than dropping the Collection.
    abstract Int deleteAll()
    
    ** Updates (and returns) the given entity.
    ** Throws an 'Err' if 'checked' and nothing was updated.
    **
    ** Will always throw 'OptimisticLockErr' if the entity contains a '_version' field which does not match what's in the
    ** database. On a successful save, this will increment the '_version' field on the entity.
    abstract Obj update(Obj entity, Bool checked := true)

    
    
    // ---- Aggregation Commands ------------------------------------------------------------------

    ** Returns the number of documents in the collection.
    abstract Int size()

    
    
    // ---- Conversion Methods --------------------------------------------------------------------
    
    ** Returns a 'MongoQ' that accepts fields as keys, and converts all values to BSON.
    ** 
    ** Use the result of query with 'find()'.
    abstract MongoQ query()
    
    ** Converts the Mongo document to an entity instance.
    **
    ** The returned object is not guaranteed to be of any particular object,
    ** for this is just a convenience for calling 'Converters.toFantom(...)'.
    abstract Obj? fromBsonDoc([Str:Obj?]? mongoDoc)

    ** Converts the entity instance to a Mongo document.
    **
    ** Convenience for calling 'Converters.toMongo(...)'.
    abstract [Str:Obj?]? toBsonDoc(Obj? entity)

    ** For internal use only. Use 'Converters' service instead.
    @NoDoc
    abstract Obj? toBson(Obj? entity)
}

internal const class DatastoreImpl : Datastore {

    override const MongoColl    collection
    override const Type         type
    override const Str          name

    private const BsonConvs     bsonConvs
    private const Field         idField
    private const Field?        versionField
    private const Func          valueHookFn

    ** Special order for IoC
    internal new make(Type type, MongoConnMgr connMgr, BsonConvs? bsonConvs, Str? dbName) {
        bsonConvs    = bsonConvs ?: BsonConvs()
        entity      := (Entity?) type.facet(Entity#, false)
        collName    := entity?.name ?: type.name
        props       := bsonConvs.propertyCache.getOrFindProps(type)

        this.bsonConvs      = bsonConvs 
        this.collection     = MongoColl(connMgr, collName, dbName)
        this.type           = type
        this.name           = collection.name
        this.idField        = props.find { it.name == "_id"      }?.field ?: throw Err("Could not find BSON property named '_id' on ${type.qname}")
        this.versionField   = props.find { it.name == "_version" }?.field
        this.valueHookFn    = #toBson.func.bind([this])

        if (versionField != null && !versionField.type.fits(Int#))
            throw Err(stripSys("_version field must be of type Int - ${versionField.qname} -> ${versionField.type.qname}"))
    }

    override MongoConnMgr connMgr() {
        collection.connMgr
    }

    override MongoDb db() {
        collection.db
    }


    
    // ---- Collection ----------------------------------------------------------------------------

    override Bool exists() {
        collection.exists
    }

    override Bool isEmpty() {
        exists && size == 0
    }

    override This drop(Bool force := false) {
        collection.drop(force)
        return this
    }



    // ---- Cursor Queries ------------------------------------------------------------------------

    override MorphiaCur find([Str:Obj?]? query := null, |MongoCmd cmd|? optsFn := null) {
        mongoCur := collection.find(query, optsFn)
        return MorphiaCur(mongoCur, type, bsonConvs)
    }
    
    override Obj? findOne(Bool checked, |MongoQ| queryFn) {
        query := this.query
        queryFn.call(query)
        entity := collection.findOne(query.query, checked)
        return (entity == null) ? null : fromBsonDoc(entity)
    }

    override Obj[] findAll(Obj? sort := null, |MongoQ|? queryFn := null) {
        query := this.query
        queryFn?.call(query)
        return find(query.query) {
            if (sort is Str) it->hint = sort
            if (sort is Map) it->sort = sort
            if (sort is Field) {
                field := (Field) sort
                prop  := (BsonProp?) field.facet(BsonProp#, false)
                name  := prop.name ?: field.name
                it->sort = [name:1]
            }
        }.toList
    }

    override Int count(|MongoQ|? queryFn := null) {
        if (queryFn == null)
            return size
        query := this.query
        queryFn.call(query)
        return collection.count(query.query)
    }

    @Operator
    override Obj? get(Obj? id, Bool checked := true) {
        if (id != null && !ReflectUtils.fits(id.typeof, idField.type))
            throw ArgErr(stripSys("ID does not fit ${idField.qname} ${idField.type.signature}# - ${id.typeof.signature} ${id}"))
        mongId := toBson(id)
        entity := collection.get(mongId, checked)
        return fromBsonDoc(entity)
    }

    
    
    // ---- Write Operations ----------------------------------------------------------------------

    override Obj insert(Obj entity) {
        if (!entity.typeof.fits(type))
            throw ArgErr("Entity of type ${entity.typeof.qname} does not fit Datastore type ${type.qname}")
        collection.insert(toBsonDoc(entity))
        return entity
    }

    override Void delete(Obj entity, Bool checked := true) {
        if (!entity.typeof.fits(type))
            throw ArgErr("Entity of type ${entity.typeof.qname} does not fit Datastore type ${type.qname}")
        id := idField.get(entity)
        deleteById(id, checked)
    }

    override Void deleteById(Obj id, Bool checked := true) {
        if (!ReflectUtils.fits(id.typeof, idField.type))
            throw ArgErr(stripSys("ID does not fit ${idField.qname} ${idField.type.signature}# - ${id.typeof.signature} ${id}"))
        mongId := toBson(id)
        n := collection.delete(["_id" : mongId])
        if (checked && n == 0)
            throw Err("Could not find Morphia entity ${type.qname} with ID: ${id}")
    }
    
    override Int deleteAll() {
        collection.deleteAll
    }

    override Obj update(Obj entity, Bool checked := true) {
        if (!entity.typeof.fits(type))
            throw ArgErr("Entity of type ${entity.typeof.qname} does not fit Datastore type ${type.qname}")
        id      := idField.get(entity)
        mongId  := toBson(id)

        if (versionField == null) {
            result := collection.replace(["_id" : mongId], toBsonDoc(entity))
            noOfMatches := result["n"]
            if (noOfMatches == 0 && checked)
                throw Err("Could not find Morphia entity ${type.qname} with ID: ${mongId}")

        } else {
            // don't user $inc & $set because then we have to $unset all the null fields!
            version := (Int) versionField.get(entity)
            toMongo := toBsonDoc(entity).set("_version", version + 1)
            result  := collection.replace(["_id" : mongId, "_version" : version], toMongo)
            noOfMatches := result["n"]

            if (noOfMatches == 0) {
                // determine which err we're gonna throw, if any
                // always throw an optimistic locking err - that's what the _version is for!
                // to 'force' a save, drop down to Mongo collections.
                if (collection.get(mongId, false) != null)
                    throw OptimisticLockErr("A newer version of ${type.qname} already exists, with ID ${mongId}", type, version)
                if (checked)
                    throw Err("Could not find Morphia entity ${type.qname} with ID: ${mongId}")
                return entity
            }

            // if all okay, attempt to inc the _version in the entity
            if (!versionField.isConst)
                versionField.set(entity, version+1)
        }
        return entity
    }
    
    

    // ---- Aggregation Commands ------------------------------------------------------------------

    override Int size() {
        collection.size
    }
    
    

    // ---- Conversion Methods --------------------------------------------------------------------

    override Obj? fromBsonDoc([Str:Obj?]? doc) {
        bsonConvs.fromBsonDoc(doc, type)
    }

    override [Str:Obj?]? toBsonDoc(Obj? entity) {
        bsonConvs.toBsonDoc(entity)
    }

    override Obj? toBson(Obj? value) {
        bsonConvs.toBsonVal(value, value?.typeof)
    }
    
    override MongoQ query() {
        MongoQ(#nameHook.func, valueHookFn)
    }

    private static Str nameHook(Obj name) {
        fieldName := name as Str

        if (name is Field) {
            field := (Field) name
            // we can't check if the field belongs to an Entity (think nested objects)
            property := (BsonProp?) field.facet(BsonProp#, false)
            fieldName = property?.name ?: field.name
        }

        if (fieldName == null)
            throw ArgErr("Key must be a Field or Str: ${name.typeof.qname} - ${name}")
        
        return fieldName
    }

    
    
    // ---- Helper Methods ------------------------------------------------------------------------

    override Str toStr() {
        "MongoDB Datastore for ${type.qname}"
    }

    static Str stripSys(Str str) {
        str.replace("sys::", "")
    }
}