Skip to content

Instantly share code, notes, and snippets.

@philcockfield
Last active January 15, 2018 10:48
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save philcockfield/5033395 to your computer and use it in GitHub Desktop.
Save philcockfield/5033395 to your computer and use it in GitHub Desktop.
Meteor - Model (Logical Document Wrappers).
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
###
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 ->
###
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]
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 ->
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 ->
###
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment