d1rk
5/26/2013 - 6:31 PM

document_model.coffee

do ->
  ###
  Base class for model Schema's
  ###
  Schema = class APP.core.Schema
    ###
    Constructor.
    @param fields:  Object(s) containing a set of field definitions
                    passed to the [define] method.
    ###
    constructor: (fields...) ->
      # Setup initial conditions.
      @fields = {}

      # Reverse the fields so that child overrides replace parent definitions.
      fields.reverse()

      # Setup the field definitions.
      define = (field) =>
          return unless field?
          for key, value of field
            @fields[key] = new APP.core.FieldDefinition(key, value)

      define( item ) for item in fields.flatten()


    ###
    Creates a new document object populated with the
    default values of the schema.
    @param schema:  The schema to generate the document from.
    ###
    createWithDefaults: ->
      doc = {}
      for key, field of @fields
        if field.hasDefault()
          value = field.default
          field.write(doc, value)
      doc


  # Schema Class Methods ------------------------------------------------------------

  Schema.isSchema       = true  # Flag used to identify the type.
  Schema.isInitialized  = false # Flag indicating if the schema has been initialized.


  ###
  Initializes the schema, copying the field definitions
  as class properties.
  ###
  Schema.init = ->
    return @ if @isInitialized is true

    instance = @singleton()
    @fields ?= instance.fields
    Object.merge @, instance.fields

    # Finish up.
    @isInitialized = true
    @



  ###
  Retrieves the singleton instance of the schema.
  ###
  Schema.singleton = ->
    return @_instance if @_instance?
    @_instance = new @()
    @_instance


  ###
  The common date fields applied to objects.
  ###
  Schema.dateFields =
    createdAt:  # The date the model was created.
      default:  -> +(new Date())
      type:     Date

    updatedAt:  # The date the model was last updated in the DB.
      default:  undefined
      type:     Date





do ->
  core = APP.ns 'core'

  ###
  A collection of models that correspond to an array of references.
  ###
  class core.ModelRefsCollection
    ###
    @param parent:        The parent model.
    @param refsKey:       The key of the property-function that contains the array.
    @param fnFactory(id): Factory method that creates a new model from the given id.
    ###
    constructor: (@parent, @refsKey, @fnFactory) ->
      throw new Error("[#{@refsKey}] not found") unless @parent[@refsKey]

    ###
    The total number of parents of the particle.
    ###
    count: -> @refs().length

    ###
    Determines whether the collection is empty.
    ###
    isEmpty: -> @count() is 0

    ###
    Gets the collection of refs
    ###
    refs: -> @parent[@refsKey]() ? []

    ###
    Retrieves the collection of models.
    ###
    toModels: ->
      return [] unless @fnFactory
      result = @refs().map (id) => @fnFactory(id)
      result.compact()


    ###
    Determines whether the given model exists within the collection.
    @param model: The model (or ID) to look for.
    ###
    contains: (model) ->
      return false unless model?
      id = model.id ? model
      if id
        @refs().indexOf(id) isnt -1
      else
        false



    ###
    Adds a new model to the collection.
    @param model: The model(s), or the model(s) ID's, to add.
    @param options
              - save: Flag indicating if the parent refs array should
                      be updated in the DB (default:true)
    ###
    add: (model, options = {}) ->
      # Setup initial conditions.
      model = [model] unless Object.isArray(model)

      add = (item) =>
                if Object.isObject(item)
                  item.insertNew() unless item.id
                id   = item.id ? item
                refs = @refs()
                return if refs.find (ref) -> ref is id # Don't add duplicates.

                # Add the reference.
                refs.add(id)
                @_writeRefs(refs, save:false)

      add(m) for m in model.compact()

      # Save if required.
      options.save ?= true
      @_saveParentRefs() if options.save is true

      # Finish up.
      @


    ###
    Removes the given object.
    @param model: The model(s), or the model(s) ID's, to remove.
    @param options
              - save: Flag indicating if the parent refs array should
                      be updated in the DB (default:true)
    ###
    remove: (model, options = {}) ->
      # Setup initial conditions.
      model = [model] unless Object.isArray(model)

      remove = (item) =>
            id   = item.id ? item
            refs = @refs()
            beforeCount = refs.length

            # Attempt to remove the reference.
            refs.remove (item) -> item is id
            if refs.length isnt beforeCount
              # An item was removed.
              @_writeRefs(refs, save:false)

      remove(m) for m in model.compact()

      # Save if required.
      options.save ?= true
      @_saveParentRefs() if options.save is true

      # Finish up.
      @


    ###
    Removes all parent references.
    @param options
              - save: Flag indicating if the particle models should
                      be updated in the DB (default:true)
    ###
    clear: (options = {}) ->
      options.save ?= true
      @_writeRefs([], save:options.save)
      @



    _writeRefs: (value, options) ->
      @parent[@refsKey](value, options)

    _saveParentRefs: -> @parent.updateFields( @parent.fields[@refsKey] )





do ->
  core = APP.ns 'core'

  instanceCount = 0


  ###
  Base class models.

  This provides a way of wrapping model logic around
  a document instance.
  ###
  core.Model = class Model
    ###
    Constructor.
    @param doc:     The document instance being wrapped.
    @param schema:  The schema Type (or an instance) that defines the model's properties.
    ###
    constructor: (doc, schema) ->
      instanceCount += 1
      @_instance     = instanceCount
      @_schema       = schema
      @_init( doc )


    _init: (doc) ->
      @_doc = doc ? {}
      unless @fields
        # First time initialization.
        if @_schema?
          applySchema( @ ) if @_schema?
          applyModelRefs( @, overwrite:false )
      else
        # This is a refresh of the document.
        applyModelRefs( @, overwrite:true )


    ###
    Disposes of the object.
    ###
    dispose: -> @isDisposed = true


    ###
    Retrieves the schema instance that defines the model.
    ###
    schema: ->
      # Setup initial conditions.
      return unless @_schema

      # Ensure the value is a Schema instance.
      if Object.isFunction( @_schema )
        throw new Error('Not a schema Type.') unless @_schema.isSchema is yes
        @_schema = @_schema.singleton()
      else
        throw new Error('Not a schema instance.') unless (@_schema instanceof core.Schema)

      # Finish up.
      @_schema


    ###
    Merges into the models document default values from the
    schema for values that are not already present.
    ###
    setDefaultValues: ->
      schema = @schema()
      if schema?
        Object.merge @_doc, schema.createWithDefaults(), true, false
      @




  # Class Properties ---------------------------------------------------------

  Model.isModelType = true # Flag used to identify the type.


  ###
  Gets the type name of the given model instance
  @param instance: The instance of the model to examine.
  ###
  Model.typeName = (instance) -> instance?.__proto__.constructor.name


  # Private ------------------------------------------------------------


  assign = (model, key, value, options = {}) ->
      unless options.overwrite is true
        throw new Error("The field '#{key}' already exists.") if model[key] isnt undefined
      model[key] = value



  applySchema = (model) ->
    schema = model.schema()

    # Store a reference to the fields.
    model.fields ?= schema.fields

    # Apply fields.
    for key, value of schema.fields
      unless value.modelRef?
        # Assign a read/write property-function.
        assign( model, key, fnField(value, model) )

      if value.hasOne?
        assign model, value.hasOne.key, fnHasOne(value, model)



  applyModelRefs = (model, options = {}) ->
    schema = model.schema()
    for key, value of schema.fields
      if value.modelRef?

        # Assign an instance of the referenced model.
        # NB: Assumes the first parameter of the constructor is the document.
        doc      = APP.ns.get( model._doc, value.field )
        instance = new value.modelRef(doc)

        # Check if the function returns the Type (rather than the instance).
        if Object.isFunction(instance) and instance.isModelType
          instance = new instance(doc)

        # Store the model-ref parent details.
        instance._parent ?= # Don't overrite an existing value.
          model: model
          field: value

        # Assign the property-function.
        assign model, key, instance, options



  fnField = (field, model) ->
    fn = (value, options) ->
            # Setup initial conditions.
            doc = model._doc

            # Write value.
            if value isnt undefined
              value = beforeWriteFilter(@, field, value, options)
              field.write(doc, value)
              afterWriteFilter(@, field, value, options)

            # Persist to mongo DB (if requsted).
            if options?.save is true
              if model.updateFields?
                # This is a [DocumentModel] that can be directly updated.
                model.updateFields?(field)
              else
                parent = model._parent
                if parent?.model.updateFields?
                  # This is a sub-document model.  Update on the parent.
                  parent.model.updateFields?( parent.field )

            # Read value.
            field.read(doc)

    # Finish up.
    copyCommonFunctionProperties(fn, field, model)
    fn



  fnDelete = (field) ->
    ->
      field.delete(@model._doc)



  fnHasOne = (field, model) ->
    fn = (value, options) ->
            read = =>
                  # Setup initial conditions.
                  hasOne      = field.hasOne
                  privateKey  = '_' + hasOne.key

                  # Look up the ID of the referenced model.
                  idRef = @[field.key]()
                  return unless idRef?

                  # Check whether the model has already been cached.
                  isCached = @[privateKey]? and @[privateKey].id is idRef
                  return @[privateKey] if isCached

                  # Construct the model from the factory.
                  @[privateKey] = hasOne.modelRef(idRef)

            write = =>
                  # Store the ID of the written object in the ref field.
                  value = beforeWriteFilter(@, field, value.id, options)
                  options ?= {}
                  options.ignoreBeforeWrite = true
                  @[field.key] value, options

            # Read and write.
            write() if value isnt undefined
            read()

    # Finish up.
    copyCommonFunctionProperties(fn, field, model)
    fn




  copyCommonFunctionProperties = (fn, field, model) ->
    field.copyTo(fn)
    fn.model = model
    fn.delete = fnDelete(field)
    fn


  beforeWriteFilter = (model, field, value, options) ->
    return value if options?.ignoreBeforeWrite is true
    writeFilter(model, field, value, options, 'beforeWrite')

  afterWriteFilter = (model, field, value, options) ->
    return value if options?.ignoreAfterWrite is true
    writeFilter(model, field, value, options, 'afterWrite')

  writeFilter = (model, field, value, options, filterKey) ->
    fnFilter = model[field.key][filterKey]
    return value unless Object.isFunction( fnFilter )
    value = fnFilter(value, options)
    value










do ->
  ###
  Represents a field on a model.
  ###
  class APP.core.FieldDefinition
    ###
    Constructor.
    @param key:        The name of the field.
    @param definition: The field as defined in the schema.
    ###
    constructor: (@key, definition) ->
      def    = definition
      @field = def?.field ? @key

      # Model-ref.
      if def?.modelRef?
        @modelRef = def.modelRef if Object.isFunction(def.modelRef)

      # Has-one ref.
      hasOne = def?.hasOne
      if hasOne?
        for refKey in [ 'key', 'modelRef' ]
          throw new Error("HasOne ref for '#{@key}' does not have a #{refKey}.") unless hasOne[refKey]?

        @hasOne =
          key:        hasOne.key
          modelRef:   hasOne.modelRef
          # type:       hasOne.type
          # collection: hasOne.collection

      # Type.
      @type = def.type if def?.type?

      # Default value.
      unless @modelRef?
        if Object.isObject(def)
          @default = def.default
        else
          @default = def
        @default = @default() if Object.isFunction(@default)


    ###
    Determines whether there is a default value.
    ###
    hasDefault: -> @default isnt undefined


    copyTo: (target) ->
      target.definition = @
      for key, value of @
        target[key] = value unless Object.isFunction(value)


    ###
    Reads the value of the field from the given document.
    @param doc:   The document object to read from.
    ###
    read: (doc) ->
      # Setup initial conditions.
      mapTo = @field

      unless mapTo.has('.')
        # Shallow read.
        value = doc[mapTo]
      else
        # Deep read.
        parts = mapTo.split('.')
        key   = parts.last()
        parts.removeAt(parts.length - 1)
        value = APP.ns.get(doc, parts)[key]

      # Process the raw value.
      if value is undefined
        value = @default

      else if @type?
        # Type conversions.
        if @type is Date and value isnt @default and not Object.isDate(value)
          value = new Date(value)

      # Finish up.
      value


    ###
    Writes the field value to the given document.
    @param doc:   The document object to write to.
    @param field: The schema field definition.
    @param value: The value to write.
    ###
    write: (doc, value) ->
      value  = @default if value is undefined
      target = docTarget(@field, doc)
      target.obj[ target.key ] = value



    ###
    Deletes the field from the document.
    ###
    delete: (doc) ->
      target = docTarget(@field, doc)
      delete target.obj[target.key]



  docTarget = (field, doc) ->
    unless field.has('.')
      # Shallow write.
      return { key:field, obj: doc }

    else
      # Deep write.
      parts  = field.split('.')
      target = doc
      for part, i in parts
        if i is parts.length - 1
          # write(target, part) # Write value.
          return { key: part, obj: target }

        else
          target[part] ?= {}
          target = target[part]





  ###
  Defines a user of the system.
  ###
  UserSchema = class ns.UserSchema extends APP.core.Schema
    constructor: (fields...) -> super fields,
      services:   undefined  # Services field that gets created by the Meteor framework
      name:                  # Name object.
        field: 'profile.name'
        modelRef: -> ns.Name
      email:
        field: 'profile.email'

      roleRefs:  # Collection of ID references to roles the user is within
        field: 'profile.roleRefs'


  ###
  Represents a user of the system.
  ###
  User = class ns.User extends APP.core.DocumentModel
    constructor: (doc) ->
      super doc, UserSchema, meteor.users
do ->
  core  = APP.ns 'core'
  Model = core.Model

  singletonManagers = {}


  ###
  Base class for models that represent Mongo documents.

  This provides a way of wrapping model logic around
  a document instance.
  ###
  core.DocumentModel = class DocumentModel extends Model
    ###
    Constructor.
    @param doc:           The document instance being wrapped.
    @param schema:        The schema Type (or an instance) that defines the model's properties.
    @param collection:    The Mongo collection the document resides within.
    ###
    constructor: (doc, schema, collection) ->
      @_collection = collection
      super doc, schema
      @id = @_doc._id


    ###
    Disposes of the object.
    ###
    dispose: ->
      super
      @_session?.dispose()
      delete @_session


    ###
    Retrieves the scoped session for the model.
    This can be used client-side for storing view state information.
    ###
    session: ->
      return if @isDisposed
      @_session = DocumentModel.session(@) unless @_session?
      @_session


    ###
    The default selector to use for updates and deletes
    ###
    defaultSelector: -> @id


    ###
    Assumes an unsaved document has been created.
    Inserts the document, and inserts the db into the document
    ###
    insertNew: ->
      # Setup initial conditions.
      throw new Error('Already exists.') if @id?

      # Ensure all default values from the schema are on the document.
      @setDefaultValues()
      doc = @_doc

      # Insert into collection.
      newId   = @_collection.insert(doc)
      doc._id = newId
      @id     = newId

      # Finish up.
      @


    ###
    Updates the document in the DB.
    @param updates:   The change instructions, eg $set:{ foo:123 }
    @param options:   Optional Mongo update options.
    ###
    update: (updates, options) ->
      @_collection.update( @defaultSelector(), updates, options )


    ###
    Updates the specified fields.
    @param fields:  The schema definitions of the fields to save.
    ###
    updateFields: (fields...) ->
      # Setup initial conditions.
      fields = fields.map (f) ->
                  if Object.isFunction(f) then f.definition else f
      fields = fields.flatten()

      # Set the 'updatedAt' timestamp if required.
      do =>
        updatedAt = @fields.updatedAt
        if updatedAt?.type?.name is 'Date'
          alreadyExists = fields.find (item) -> item.key is updatedAt.key
          unless alreadyExists
            @updatedAt +(new Date())
            fields.add( updatedAt )

      # Build the change-set.
      change = {}
      for item in fields
        prop = @[item.key]
        if item.modelRef?
          # A referenced model. Pass the sub-document to be saved.
          value = prop._doc
        else
          # A property-func, read the value.
          value = prop.apply(@)
          # Cast it to an integer if it's a date - see http://stackoverflow.com/questions/2831345/is-there-a-way-to-check-if-a-variable-is-a-date-in-javascript
          value = +(value) if Object.prototype.toString.call(value) == "[object Date]" #

        # Store the value on the change-set
        change[item.field] = value

      # Save.
      @update $set:change


    ###
    Deletes the model.
    ###
    delete: -> @_collection.remove( @defaultSelector() )


    ###
    Re-queries the document from the collection.
    ###
    refresh: ->
      return unless @_collection? and @_schema?
      doc = @_collection.findOne( @id )
      @_init( doc ) if doc?
      @


  # Class Properties ---------------------------------------------------------


  DocumentModel.isDocumentModelType = true # Flag used to identify the type.


  ###
  Gets the scoped-session singleton for the given model instance.
  @param instance: The [DocumentModel] instance to retrieve the session for.
  ###
  DocumentModel.session = (instance) ->
    return unless instance?.id?
    core.ScopedSession.singleton( "#{ Model.typeName(instance) }:#{instance.id}" )


  ###
  Retrieves a singleton instance of a model.
  @param id:        The model/document ID.
  @param fnFactory: The factory method (or Type) to create the model with if it does not already exist.
  ###
  DocumentModel.singleton = (id, fnFactory) ->
    # Setup initial conditions.
    return unless id?
    doc = id if id._id
    id  = doc._id if doc?

    # Create the instance if necessary.
    unless instances[id]?
      if fnFactory?.isDocumentModelType and doc?
        # Create from Type.
        instances[id] = new fnFactory(doc)

      else if Object.isFunction(fnFactory)
        # Create from factory function.
        instances[id] = fnFactory?(id)

    # Retrieve the model.
    model = instances[id]
    manageSingletons(model?._collection) # Ensure singletons are removed.
    model

  DocumentModel.instances = instances = {}
  manageSingletons = do ->
    collections = {}
    (col) ->
          return unless col?
          return if collections[col._name]?
          collections[col._name] = col
          col.find().observe
            removed: (oldDoc) ->
                        # console.log 'single removed', oldDoc
                        id    = oldDoc._id
                        model = instances[id]
                        model?.dispose()
                        delete instances[id]




  ###
  Writes a debug log of the model instances.
  ###
  instances.write = ->
    items = []
    add = (instance) -> items.add(instance) unless Object.isFunction(value)
    add(value) for key, value of instances
    console.log "core.DocumentModel.instances (singletons): #{items.length}"
    for instance in items
      console.log ' > ', instance