sourceafMorphia::Datastore.fan

using afBeanUtils::NotFoundErr
using afBeanUtils::ReflectUtils
using afIoc
using afMongo

** (Service) -
** Wraps a MongoDB [Collection]`afMongo::Collection`, converting Fantom entities to / from Mongo documents.
** 
** When injecting as a service, use the '@Inject.type' attribute to state which Entity type it is for: 
** 
**   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 underlying MongoDB collection this Datastore wraps.
    abstract Collection collection()

    ** The Fantom entity type this Datastore associates with.
    abstract Type   type()
    
    ** The qualified name of the MongoDB collection.
    ** It takes the form of: 
    ** 
    **   <database>.<collection>
    abstract Str qname()

    ** The simple name of the MongoDB collection.
    abstract Str name()
    
    // ---- Collection ----------------------------------------------------------------------------

    ** Returns 'true' if the underlying MongoDB collection exists.
    ** 
    ** @see `afMongo::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.
    ** 
    ** @see `afMongo::Collection.drop`
    abstract This drop(Bool force := false)

    // ---- Cursor Queries ------------------------------------------------------------------------
    
    ** An (optimised) method to return one document from the given 'query'.
    ** 
    ** Note: This method requires you to be familiar with Mongo query notation. If not, use the `Query` builder instead.
    ** 
    ** Throws 'MongoErr' if no documents are found and 'checked' is 'true', returns 'null' otherwise.
    ** Always throws 'MongoErr' if the query returns more than one document.
    **  
    ** @see `afMongo::Collection.findOne`
    abstract Obj? findOne([Str:Obj?]? query := null, Bool checked := true)

    ** Returns a list of entities that match the given 'query'.
    ** 
    ** Note: This method requires you to be familiar with Mongo query notation. If not, use the `Query` builder instead.
    ** 
    ** If 'sort' is a Str it should the name of an index to use as a hint.
    **  
    ** If 'sort' is a '[Str:Obj?]' map, it should be a sort document with field names as keys. 
    ** Values may either be the standard Mongo '1' and '-1' for ascending / descending or the 
    ** strings 'ASC' / 'DESC'.
    ** 
    ** The 'sort' map, should it contain more than 1 entry, must be ordered.
    ** 
    ** @see `afMongo::Collection.findAll`
    abstract Obj[] findAll([Str:Obj?]? query := null, Obj? sort := null, Int skip := 0, Int? limit := null, [Str:Obj?]? projection := null)

    ** Returns the number of documents that would be returned by the given 'query'.
    ** 
    ** Note: This method requires you to be familiar with Mongo query notation. If not, use the `Query` builder instead.
    ** 
    ** @see `afMongo::Collection.findCount`
    abstract Int findCount([Str:Obj?]? query := 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.
    ** 
    ** @see `afMongo::Collection.insert`
    abstract Obj insert(Obj entity)

    ** Deletes the given entity from the MongoDB.
    ** Throws 'MorphiaErr' if 'checked' and nothing was deleted. 
    ** 
    ** @see `afMongo::Collection.delete`
    abstract Void delete(Obj entity, Bool checked := true)

    ** Deletes entity with the given Id.
    ** Throws 'MorphiaErr' if 'checked' and nothing was deleted. 
    ** 
    ** @see `afMongo::Collection.delete`
    abstract Void deleteById(Obj id, Bool checked := true)

    ** Updates the given entity.
    ** Throws 'MorphiaErr' if 'checked' and nothing was updated. 
    ** 
    ** @see `afMongo::Collection.update`
    abstract Void update(Obj entity, Bool? upsert := false, Bool checked := true)

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

    ** Returns the number of documents in the collection.
    ** 
    ** @see `afMongo::Collection.size`
    abstract Int size()
    
    // ---- Conversion Methods --------------------------------------------------------------------
    
    ** 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 fromMongoDoc(Str:Obj? mongoDoc)
    
    ** Converts the entity instance to a Mongo document.
    ** 
    ** Convenience for calling 'Converters.toMongo(...)'.
    abstract Str:Obj? toMongoDoc(Obj entity)

    // ---- Query Methods -------------------------------------------------------------------------
    
    ** Use to execute the given query.
    abstract QueryExecutor query(Query? query := null)

}

internal const class DatastoreImpl : Datastore {
    
    override const Collection   collection
    override const Type         type
    override const Str          qname
    override const Str          name
    
    @Inject
    private const Converters    converters
    private const Field         idField
    
    internal new make(Type type, Database database, |This|in) {
        in(this)

        this.collection = Collection(database, Utils.entityName(type))
        this.type       = verifyEntityType(type)
        this.qname      = collection.qname
        this.name       = collection.name
        this.idField    = type.fields.findAll { it.hasFacet(Property#) }.find |field->Bool| {
            property := (Property) field.facet(Property#)
            return field.name == "_id" || property.name == "_id"
        } ?: throw IdNotFoundErr(ErrMsgs.datastore_idFieldNotFound(type), propertyNames(type))

    }

    // ---- 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 Obj? findOne([Str:Obj?]? query := null, Bool checked := true) {
        entity := collection.findOne(query, checked)
        return (entity == null) ? null : fromMongoDoc(entity)
    }

    override Obj[] findAll([Str:Obj?]? query := null, Obj? sort := null, Int skip := 0, Int? limit := null, [Str:Obj?]? projection := null) {
        all := collection.findAll(query, sort, skip, limit, projection)
        // ensure empty lists are correctly typed
        list := List.make(type, all.size)
        all.each { list.add(fromMongoDoc(it)) }
        return list
    }

    override Int findCount([Str:Obj?]? query := null) {
        collection.findCount(query)
    }

    @Operator
    override Obj? get(Obj? id, Bool checked := true) {
        if (id != null && !ReflectUtils.fits(id.typeof, idField.type))
            throw ArgErr(ErrMsgs.datastore_idDoesNotFit(id, idField))
        mongId := toMongo(id)
        entity := collection.get(mongId, checked)
        return (entity == null) ? null : fromMongoDoc(entity)
    }

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

    override Obj insert(Obj entity) {
        if (!entity.typeof.fits(type))
            throw ArgErr(ErrMsgs.datastore_entityWrongType(entity.typeof, type))
        collection.insert(toMongoDoc(entity))
        return entity
    }

    override Void delete(Obj entity, Bool checked := true) {
        if (!entity.typeof.fits(type))
            throw ArgErr(ErrMsgs.datastore_entityWrongType(entity.typeof, type))
        id := idField.get(entity)
        deleteById(id, checked)
    }

    override Void deleteById(Obj id, Bool checked := true) {
        if (!ReflectUtils.fits(id.typeof, idField.type))
            throw ArgErr(ErrMsgs.datastore_idDoesNotFit(id, idField))
        mongId := toMongo(id)
        n := collection.delete(["_id" : mongId], false)
        if (checked && n != 1)
            throw MorphiaErr(ErrMsgs.datastore_entityNotFound(type, id))
    }

    override Void update(Obj entity, Bool? upsert := false, Bool checked := true) {
        if (!entity.typeof.fits(type))
            throw ArgErr(ErrMsgs.datastore_entityWrongType(entity.typeof, type))
        id := idField.get(entity)
        mongId := toMongo(id)
        noOfUpdates := collection.update(["_id" : mongId], toMongoDoc(entity), false, upsert)
        if (noOfUpdates == 0 && upsert == false && checked) {
            // Mongo reports zero updates if the given document hasn't changed,
            // as this isn't an error let it slide...
            if (collection.get(mongId, false) == null)
                throw MorphiaErr(ErrMsgs.datastore_entityNotFound(type, mongId))
        }
    }

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

    override Int size() {
        collection.size
    }
    
    // ---- Conversion Methods --------------------------------------------------------------------
    
    override Obj fromMongoDoc(Str:Obj? mongoDoc) {
        converters.toFantom(type, mongoDoc)
    }
    
    override Str:Obj? toMongoDoc(Obj entity) {
        converters.toMongo(type, entity)        
    }   

    internal Obj? toMongo(Obj? entity) {
        entity == null ? null : converters.toMongo(entity.typeof, entity)       
    }   

    // ---- Query Methods -------------------------------------------------------------------------
    
    override QueryExecutor query(Query? query := null) {
        // nullable query so we can do: ds.query.findAll
        QueryExecutor(this, query ?: Query())
    }
    
    // ---- Helper Methods ------------------------------------------------------------------------
    
    internal Type verifyEntityType(Type type) {
        // try to use the ObjConverter hook to find property fields
        fields := (Field[]?) (converters.get(Obj#) as ObjConverter)?.findPropertyFields(type)
        
        // if null, we can't be sure what fields are valid, so skip validation
        if (fields == null)
            return type
    
        names := Str:Field[:]
        fields.each |field| {
            pName := Utils.propertyName(field)
            pType := Utils.propertyType(field)
            if (!ReflectUtils.fits(pType, field.type))
                throw MorphiaErr(ErrMsgs.datastore_facetTypeDoesNotFitField(pType, field))
            if (names.containsKey(pName))
                throw MorphiaErr(ErrMsgs.datastore_duplicatePropertyName(pName, names[pName], field))
            names[pName] = field 
        }
        return type
    }
    
    private static Str[] propertyNames(Type type) {
        type.fields.findAll { it.hasFacet(Property#) }.map |field->Str| {
            property := (Property) field.facet(Property#)
            return property.name ?: field.name          
        }
    }
}

@NoDoc
const class IdNotFoundErr : Err, NotFoundErr {  
    override const Str?[] availableValues
    
    new make(Str msg, Obj?[] availableValues, Err? cause := null) : super(msg, cause) {
        this.availableValues = availableValues.map { it?.toStr }.sort
    }
    
    override Str toStr() {
        NotFoundErr.super.toStr
    }
}