/**
*
* @constructor
* @memberof Rekord
* @augments Rekord.Eventful
*/
function Database(options)
{
var defaults = Database.Defaults;
// Apply the options to this database!
applyOptions( this, options, defaults );
// Apply options not specified in defaults
for (var prop in options)
{
if ( !(prop in defaults) )
{
this[ prop ] = options[ prop ];
}
}
// If key fields aren't in fields array, add them in
var key = this.key;
var fields = this.fields;
if ( isArray( key ) )
{
for (var i = key.length - 1; i >= 0; i--)
{
if ( indexOf( fields, key[ i ] ) === false )
{
fields.unshift( key[ i ] );
}
}
}
else // isString( key )
{
if ( indexOf( fields, key ) === false )
{
fields.unshift( key );
}
}
// Properties
this.keys = toArray( this.key );
this.models = new ModelCollection( this );
this.all = {};
this.loaded = {};
this.className = this.className || toCamelCase( this.name );
this.initialized = false;
this.pendingRefresh = false;
this.localLoaded = false;
this.remoteLoaded = false;
this.firstRefresh = false;
this.pendingOperations = 0;
this.afterOnline = false;
this.saveFields = copy( fields );
this.readyPromise = new Promise( null, false );
// Prepare
this.prepare( this, options );
// Services
this.rest = this.createRest( this );
this.store = this.createStore( this );
this.live = this.createLive( this );
// Functions
this.setComparator( this.comparator, this.comparatorNullsFirst );
this.setRevision( this.revision );
this.setSummarize( this.summarize );
// Relations
this.relations = {};
this.relationNames = [];
for (var relationType in options)
{
if ( !(relationType in Rekord.Relations) )
{
continue;
}
var RelationClass = Rekord.Relations[ relationType ];
if ( !(RelationClass.prototype instanceof Relation ) )
{
continue;
}
var relationMap = options[ relationType ];
for ( var name in relationMap )
{
var relationOptions = relationMap[ name ];
var relation = new RelationClass();
relation.init( this, name, relationOptions );
if ( relation.save )
{
this.saveFields.push( name );
}
this.relations[ name ] = relation;
this.relationNames.push( name );
}
}
}
function defaultEncode(model, data, forSaving)
{
var encodings = this.encodings;
for (var prop in data)
{
if ( prop in encodings )
{
data[ prop ] = encodings[ prop ]( data[ prop ], model, prop, forSaving );
}
}
return data;
}
function defaultDecode(rawData)
{
var decodings = this.decodings;
for (var prop in rawData)
{
if ( prop in decodings )
{
rawData[ prop ] = decodings[ prop ]( rawData[ prop ], rawData, prop );
}
}
return rawData;
}
function defaultSummarize(model)
{
return model.$key();
}
function defaultCreateRest(database)
{
return Rekord.rest( database );
}
function defaultCreateStore(database)
{
return Rekord.store( database );
}
function defaultCreateLive( database )
{
return Rekord.live( database );
}
function defaultResolveModel( response )
{
return response;
}
function defaultResolveModels( response )
{
return response;
}
Database.Events =
{
NoLoad: 'no-load',
RemoteLoad: 'remote-load',
LocalLoad: 'local-load',
Updated: 'updated',
ModelAdded: 'model-added',
ModelUpdated: 'model-updated',
ModelRemoved: 'model-removed',
OperationsStarted: 'operations-started',
OperationsFinished: 'operations-finished',
Loads: 'no-load remote-load local-load',
Changes: 'updated'
};
Database.Defaults =
{
name: undefined, // required
className: null, // defaults to toCamelCase( name )
key: 'id',
keySeparator: '/',
fields: [],
ignoredFields: {},
defaults: {},
publishAlways: [],
comparator: null,
comparatorNullsFirst: null,
revision: null,
cascade: Cascade.All,
load: Load.None,
allComplete: false,
loadRelations: true,
autoRefresh: true,
cache: Cache.All,
fullSave: false,
fullPublish: false,
encodings: {},
decodings: {},
prepare: noop,
encode: defaultEncode,
decode: defaultDecode,
resolveModel: defaultResolveModel,
resolveModels: defaultResolveModels,
summarize: defaultSummarize,
createRest: defaultCreateRest,
createStore: defaultCreateStore,
createLive: defaultCreateLive
};
addMethods( Database.prototype,
{
// Notifies a callback when the database has loaded (either locally or remotely).
ready: function(callback, context, persistent)
{
return this.readyPromise.success( callback, context, persistent );
},
// Determines whether the given object has data to save
hasData: function(saving)
{
if ( !isObject( saving ) )
{
return false;
}
for (var prop in saving)
{
if ( !this.ignoredFields[ prop ] )
{
return true;
}
}
return false;
},
// Grab a model with the given input and notify the callback
grabModel: function(input, callback, context, remoteData)
{
var db = this;
var promise = new Promise();
promise.success( callback, context || db );
function checkModel()
{
var result = db.parseModel( input, remoteData );
if ( result !== false && !promise.isComplete() && db.initialized )
{
var remoteLoaded = db.remoteLoaded || !db.hasLoad( Load.All );
var missingModel = (result === null || !result.$isSaved());
var lazyLoad = db.hasLoad( Load.Lazy );
if ( lazyLoad && remoteLoaded && missingModel )
{
if ( !result )
{
result = db.buildObjectFromKey( db.buildKeyFromInput( input ) );
}
result.$once( Model.Events.RemoteGets, function()
{
if ( !promise.isComplete() )
{
if ( isObject( input ) )
{
result.$set( input );
}
promise.resolve( result.$isSaved() ? result : null );
}
});
result.$refresh();
}
else
{
promise.resolve( result );
}
}
return promise.isComplete() ? false : true;
}
if ( checkModel() )
{
db.ready( checkModel, db, true );
}
return promise;
},
// Parses the model from the given input
//
// Returns false if the input doesn't resolve to a model at the moment
// Returns null if the input doesn't resolve to a model and all models have been remotely loaded
//
// parseModel( Rekord )
// parseModel( Rekord.Model )
// parseModel( 'uuid' )
// parseModel( ['uuid'] )
// parseModel( modelInstance )
// parseModel( {name:'new model'} )
// parseModel( {id:4, name:'new or existing model'} )
//
parseModel: function(input, remoteData)
{
var db = this;
var hasRemote = db.remoteLoaded || !db.hasLoad( Load.All );
if ( !isValue( input ) )
{
return hasRemote ? null : false;
}
if ( isRekord( input ) )
{
input = new input();
}
if ( isFunction( input ) )
{
input = input();
}
var key = db.buildKeyFromInput( input );
if ( input instanceof db.Model )
{
return input;
}
else if ( key in db.all )
{
var model = db.all[ key ];
if ( isObject( input ) )
{
this.buildKeyFromRelations( input );
if ( remoteData )
{
db.putRemoteData( input, key, model );
}
else
{
model.$set( input );
}
}
return model;
}
else if ( isObject( input ) )
{
this.buildKeyFromRelations( input );
if ( remoteData )
{
return db.putRemoteData( input );
}
else
{
return db.instantiate( db.decode( input ) );
}
}
else if ( hasRemote )
{
return null;
}
return false;
},
// Removes the key from the given model
removeKey: function(model)
{
var k = this.key;
if ( isArray(k) )
{
for (var i = 0; i < k.length; i++)
{
delete model[ k[i] ];
}
}
else
{
delete model[ k ];
}
},
// Builds a key string from the given model and array of fields
buildKey: function(model, fields)
{
var key = this.buildKeys( model, fields );
if ( isArray( key ) )
{
key = key.join( this.keySeparator );
}
return key;
},
buildKeyFromRelations: function(input)
{
if ( isObject( input ) )
{
for (var relationName in this.relations)
{
if ( relationName in input )
{
this.relations[ relationName ].buildKey( input );
}
}
}
},
// Builds a key (possibly array) from the given model and array of fields
buildKeys: function(model, fields)
{
var key = null;
this.buildKeyFromRelations( model );
if ( isArray( fields ) )
{
key = [];
for (var i = 0; i < fields.length; i++)
{
key.push( model[ fields[i] ] );
}
}
else
{
key = model[ fields ];
if (!key)
{
key = model[ fields ] = uuid();
}
}
return key;
},
// Builds a key from various types of input.
buildKeyFromInput: function(input)
{
if ( input instanceof this.Model )
{
return input.$key();
}
else if ( isArray( input ) ) // && isArray( this.key )
{
return this.buildKeyFromArray( input );
}
else if ( isObject( input ) )
{
return this.buildKey( input, this.key );
}
return input;
},
// Builds a key from an array
buildKeyFromArray: function(arr)
{
return arr.join( this.keySeparator );
},
// Gets the key from the given model
getKey: function(model, quietly)
{
var key = this.key;
var modelKey = this.buildKey( model, key );
if ( hasFields( model, key, isValue ) )
{
return modelKey;
}
else if ( !quietly )
{
throw 'Composite key not supplied.';
}
return false;
},
// Gets the key from the given model
getKeys: function(model)
{
return this.buildKeys( model, this.key );
},
buildObjectFromKey: function(key)
{
var db = this;
var props = {};
if ( isArray( db.key ) )
{
if ( isString( key ) )
{
key = key.split( db.keySeparator );
}
for (var i = 0; i < db.key.length; i++)
{
props[ db.key[ i ] ] = key[ i ];
}
}
else
{
props[ db.key ] = key;
}
return db.instantiate( props );
},
// Sorts the models & notifies listeners that the database has been updated.
updated: function()
{
this.sort(); // TODO remove
this.trigger( Database.Events.Updated );
},
// Sets a revision comparision function for this database. It can be a field
// name or a function. This is used to avoid updating model data that is older
// than the model's current data.
setRevision: function(revision)
{
if ( isFunction( revision ) )
{
this.revisionFunction = revision;
}
else if ( isString( revision ) )
{
this.revisionFunction = function(a, b)
{
var ar = isObject( a ) && revision in a ? a[ revision ] : undefined;
var br = isObject( b ) && revision in b ? b[ revision ] : undefined;
return ar === undefined || br === undefined ? false : compare( ar, br ) > 0;
};
}
else
{
this.revisionFunction = function(a, b)
{
return false;
};
}
},
// Sets a comparator for this database. It can be a field name, a field name
// with a minus in the front to sort in reverse, or a comparator function.
setComparator: function(comparator, nullsFirst)
{
this.models.setComparator( comparator, nullsFirst );
},
addComparator: function(comparator, nullsFirst)
{
this.models.addComparator( comparator, nullsFirst );
},
setSummarize: function(summarize)
{
if ( isFunction( summarize ) )
{
this.summarize = summarize;
}
else if ( isString( summarize ) )
{
if ( indexOf( this.fields, summarize ) !== false )
{
this.summarize = function(model)
{
return isValue( model ) ? model[ summarize ] : model;
};
}
else
{
this.summarize = createFormatter( summarize );
}
}
else
{
this.summarize = function(model)
{
return model.$key();
};
}
},
// Sorts the database if it isn't sorted.
sort: function()
{
this.models.sort();
},
// Determines whether this database is sorted.
isSorted: function()
{
return this.models.isSorted();
},
clean: function()
{
var db = this;
var keys = db.models.keys;
var models = db.models;
db.all = {};
for (var i = 0; i < keys.length; i++)
{
db.all[ keys[ i ] ] = models[ i ];
}
},
// Handles when we receive data from the server - either from
// a publish, refresh, or values being returned on a save.
putRemoteData: function(encoded, key, model, overwrite)
{
if ( !isObject( encoded ) )
{
return model;
}
var db = this;
var key = key || db.getKey( encoded );
var model = model || db.all[ key ];
var decoded = db.decode( copy( encoded ) );
// Reject the data if it's a lower revision
if ( model )
{
var revisionRejected = this.revisionFunction( model, encoded );
if ( revisionRejected )
{
Rekord.debug( Rekord.Debugs.SAVE_OLD_REVISION, db, model, encoded );
return model;
}
}
// If the model already exists, update it.
if ( model )
{
var keyFields = db.keys;
for (var i = 0; i < keyFields.length; i++)
{
var k = keyFields[ i ];
var mk = model[ k ];
var dk = decoded[ k ];
if ( isValue( mk ) && isValue( dk ) && mk !== dk )
{
throw new Error('Model keys cannot be changed');
}
}
db.all[ key ] = model;
if ( !model.$saved )
{
model.$saved = {};
}
var current = model.$toJSON( true );
var conflicts = {};
var conflicted = false;
var updated = {};
var notReallySaved = isEmpty( model.$saved );
var relations = db.relations;
for (var prop in encoded)
{
if ( prop.charAt(0) === '$' )
{
continue;
}
if ( prop in relations )
{
model.$set( prop, encoded[ prop ], true );
continue;
}
var currentValue = current[ prop ];
var savedValue = model.$saved[ prop ];
if ( notReallySaved || overwrite || equals( currentValue, savedValue ) )
{
model[ prop ] = decoded[ prop ];
updated[ prop ] = encoded[ prop ];
if ( model.$local )
{
model.$local[ prop ] = encoded[ prop ];
}
}
else
{
conflicts[ prop ] = encoded[ prop ];
conflicted = true;
}
model.$saved[ prop ] = copy( encoded[ prop ] );
}
if ( conflicted )
{
model.$trigger( Model.Events.PartialUpdate, [encoded, conflicts] );
}
else
{
model.$trigger( Model.Events.FullUpdate, [encoded, updated] );
}
model.$trigger( Model.Events.RemoteUpdate, [encoded] );
model.$addOperation( SaveNow );
if ( !db.models.has( key ) )
{
db.models.put( key, model );
db.trigger( Database.Events.ModelAdded, [model, true] );
}
}
// The model doesn't exist, create it.
else
{
model = db.createModel( decoded, true );
if ( db.cache === Cache.All )
{
model.$local = model.$toJSON( false );
model.$local.$status = model.$status;
model.$saved = model.$local.$saved = model.$toJSON( true );
model.$addOperation( SaveNow );
}
else
{
model.$saved = model.$toJSON( true );
}
}
return model;
},
createModel: function(decoded, remoteData)
{
var db = this;
var model = db.instantiate( decoded, remoteData );
var key = model.$key();
if ( !db.models.has( key ) )
{
db.models.put( key, model );
db.trigger( Database.Events.ModelAdded, [model, remoteData] );
}
return model;
},
destroyModel: function(model, modelKey)
{
var db = this;
var key = modelKey || model.$key();
delete db.all[ key ];
db.models.remove( key );
db.trigger( Database.Events.ModelRemoved, [model] );
model.$trigger( Model.Events.RemoteAndRemove );
Rekord.debug( Rekord.Debugs.REMOTE_REMOVE, db, model );
},
destroyLocalUncachedModel: function(model, key)
{
var db = this;
if ( model )
{
if ( model.$hasChanges() )
{
delete model.$saved;
db.removeKey( model );
model.$trigger( Model.Events.Detach );
return false;
}
db.destroyModel( model, key );
return true;
}
return false;
},
destroyLocalCachedModel: function(model, key)
{
var db = this;
if ( model )
{
// If a model was removed remotely but the model has changes - don't remove it.
if ( model.$hasChanges() )
{
// Removed saved history and the current ID
delete model.$saved;
db.removeKey( model );
if ( model.$local )
{
delete model.$local.$saved;
db.removeKey( model.$local );
}
model.$trigger( Model.Events.Detach );
model.$addOperation( SaveNow );
return false;
}
model.$addOperation( RemoveNow );
db.destroyModel( model, key );
}
else
{
db.store.remove( key, function(removedValue)
{
if (removedValue)
{
Rekord.debug( Rekord.Debugs.REMOTE_REMOVE, db, removedValue );
}
});
// The model didn't exist
return false;
}
return true;
},
// Destroys a model locally because it doesn't exist remotely
destroyLocalModel: function(key)
{
var db = this;
var model = db.all[ key ];
if ( db.cache === Cache.All )
{
return db.destroyLocalCachedModel( model, key );
}
else
{
return db.destroyLocalUncachedModel( model, key );
}
},
loadFinish: function()
{
var db = this;
batchExecute(function()
{
for (var key in db.loaded)
{
var model = db.loaded[ key ];
if ( model.$status === Model.Status.RemovePending )
{
Rekord.debug( Rekord.Debugs.LOCAL_RESUME_DELETE, db, model );
model.$addOperation( RemoveRemote );
}
else
{
if ( model.$status === Model.Status.SavePending )
{
Rekord.debug( Rekord.Debugs.LOCAL_RESUME_SAVE, db, model );
model.$addOperation( SaveRemote );
}
else
{
Rekord.debug( Rekord.Debugs.LOCAL_LOAD_SAVED, db, model );
}
db.models.put( key, model, true );
}
}
});
db.loaded = {};
db.updated();
if ( db.hasLoad( Load.All ) )
{
if ( db.pendingOperations === 0 )
{
db.refresh();
}
else
{
db.firstRefresh = true;
}
}
},
hasLoad: function(load)
{
return (this.load & load) !== 0;
},
loadBegin: function(onLoaded)
{
var db = this;
function onLocalLoad(records, keys)
{
Rekord.debug( Rekord.Debugs.LOCAL_LOAD, db, records );
for (var i = 0; i < records.length; i++)
{
var encoded = records[ i ];
var key = keys[ i ];
var decoded = db.decode( copy( encoded, true ) );
var model = db.instantiate( decoded, true );
model.$local = encoded;
model.$saved = encoded.$saved;
if ( model.$status !== Model.Status.Removed )
{
db.loaded[ key ] = model;
db.all[ key ] = model;
}
}
db.localLoaded = true;
db.triggerLoad( Database.Events.LocalLoad );
onLoaded( true, db );
}
function onLocalError()
{
db.loadNone();
onLoaded( false, db );
}
if ( db.hasLoad( Load.All ) && db.autoRefresh )
{
Rekord.after( Rekord.Events.Online, db.onOnline, db );
}
if ( db.cache === Cache.None )
{
db.loadNone();
onLoaded( false, db );
}
else
{
db.store.all( onLocalLoad, onLocalError );
}
},
triggerLoad: function(loadEvent, additionalParameters)
{
var db = this;
db.initialized = true;
db.trigger( loadEvent, [ db ].concat( additionalParameters || [] ) );
db.readyPromise.reset().resolve( db );
},
loadNone: function()
{
var db = this;
if ( db.hasLoad( Load.All ) )
{
db.refresh();
}
else
{
db.triggerLoad( Database.Events.NoLoad );
}
},
onOnline: function()
{
var db = this;
db.afterOnline = true;
if ( db.pendingOperations === 0 )
{
db.onOperationRest();
}
},
onOperationRest: function()
{
var db = this;
if ( ( db.autoRefresh && db.remoteLoaded && db.afterOnline ) || db.firstRefresh )
{
db.afterOnline = false;
db.firstRefresh = false;
Rekord.debug( Rekord.Debugs.AUTO_REFRESH, db );
db.refresh();
}
},
handleRefreshSuccess: function(promise)
{
var db = this;
return function onRefreshSuccess(response)
{
var models = db.resolveModels( response );
var mapped = {};
for (var i = 0; i < models.length; i++)
{
var model = db.putRemoteData( models[ i ] );
if ( model )
{
var key = model.$key();
mapped[ key ] = model;
}
}
if ( db.allComplete )
{
var keys = db.models.keys().slice();
for (var i = 0; i < keys.length; i++)
{
var k = keys[ i ];
if ( !(k in mapped) )
{
var old = db.models.get( k );
if ( old.$saved )
{
Rekord.debug( Rekord.Debugs.REMOTE_LOAD_REMOVE, db, k );
db.destroyLocalModel( k );
}
}
}
}
db.remoteLoaded = true;
db.triggerLoad( Database.Events.RemoteLoad );
db.updated();
Rekord.debug( Rekord.Debugs.REMOTE_LOAD, db, models );
promise.resolve( db.models );
};
},
handleRefreshFailure: function(promise)
{
var db = this;
return function onRefreshFailure(response, status)
{
if ( status === 0 )
{
Rekord.checkNetworkStatus();
if ( !Rekord.online )
{
db.pendingRefresh = true;
Rekord.once( Rekord.Events.Online, db.onRefreshOnline, db );
}
Rekord.debug( Rekord.Debugs.REMOTE_LOAD_OFFLINE, db );
}
else
{
Rekord.debug( Rekord.Debugs.REMOTE_LOAD_ERROR, db, status );
db.triggerLoad( Database.Events.NoLoad, [response] );
}
promise.reject( db.models );
};
},
executeRefresh: function(success, failure)
{
this.rest.all( success, failure );
},
// Loads all data remotely
refresh: function(callback, context)
{
var db = this;
var promise = new Promise();
var success = this.handleRefreshSuccess( promise );
var failure = this.handleRefreshFailure( promise );
promise.complete( callback, context || db );
batchExecute(function()
{
db.executeRefresh( success, failure );
});
return promise;
},
onRefreshOnline: function()
{
var db = this;
Rekord.debug( Rekord.Debugs.REMOTE_LOAD_RESUME, db );
if ( db.pendingRefresh )
{
db.pendingRefresh = false;
db.refresh();
}
},
// Returns a model
get: function(key)
{
return this.all[ this.buildKeyFromInput( key ) ];
},
filter: function(isValid)
{
var all = this.all;
var filtered = [];
for (var key in all)
{
var model = all[ key ];
if ( isValid( model ) )
{
filtered.push( model );
}
}
return filtered;
},
liveSave: function(key, encoded)
{
this.putRemoteData( encoded, key );
this.updated();
Rekord.debug( Rekord.Debugs.REALTIME_SAVE, this, encoded, key );
},
liveRemove: function(key)
{
if ( this.destroyLocalModel( key ) )
{
this.updated();
}
Rekord.debug( Rekord.Debugs.REALTIME_REMOVE, this, key );
},
// Return an instance of the model with the data as initial values
instantiate: function(data, remoteData)
{
return new this.Model( data, remoteData );
},
addReference: function(model)
{
this.all[ model.$key() ] = model;
},
// Save the model
save: function(model, cascade)
{
var db = this;
if ( model.$isDeleted() )
{
Rekord.debug( Rekord.Debugs.SAVE_DELETED, db, model );
return;
}
var key = model.$key();
var existing = db.models.has( key );
if ( existing )
{
db.trigger( Database.Events.ModelUpdated, [model] );
model.$trigger( Model.Events.UpdateAndSave );
}
else
{
db.models.put( key, model );
db.trigger( Database.Events.ModelAdded, [model] );
db.updated();
model.$trigger( Model.Events.CreateAndSave );
}
model.$addOperation( SaveLocal, cascade );
},
// Remove the model
remove: function(model, cascade)
{
var db = this;
// If we have it in the models, remove it!
this.removeFromModels( model );
// If we're offline and we have a pending save - cancel the pending save.
if ( model.$status === Model.Status.SavePending )
{
Rekord.debug( Rekord.Debugs.REMOVE_CANCEL_SAVE, db, model );
}
model.$status = Model.Status.RemovePending;
model.$addOperation( RemoveLocal, cascade );
},
removeFromModels: function(model)
{
var db = this;
var key = model.$key();
if ( db.models.has( key ) )
{
db.models.remove( key );
db.trigger( Database.Events.ModelRemoved, [model] );
db.updated();
model.$trigger( Model.Events.Removed );
}
}
});
addEventful( Database.prototype );
addEventFunction( Database.prototype, 'change', Database.Events.Changes );