/**
@module ember-flexberry-data
*/
import EmberMap from '@ember/map';
import RSVP from 'rsvp';
import $ from 'jquery';
import { getOwner } from '@ember/application';
import { inject as service } from '@ember/service';
import { A } from '@ember/array';
import { isNone, isEmpty } from '@ember/utils';
import { merge } from '@ember/polyfills';
import { assert, warn } from '@ember/debug';
import DS from 'ember-data';
import isObject from '../utils/is-object';
import generateUniqueId from '../utils/generate-unique-id';
import IndexedDBAdapter from '../query/indexeddb-adapter';
import QueryObject from '../query/query-object';
import QueryBuilder from '../query/builder';
import FilterOperator from '../query/filter-operator';
import Condition from '../query/condition';
import { SimplePredicate, ComplexPredicate } from '../query/predicate';
import Dexie from 'npm:dexie';
import Information from '../utils/information';
/**
Default adapter for {{#crossLink "Offline.LocalStore"}}{{/crossLink}}.
@class Offline
@extends <a href="http://emberjs.com/api/data/classes/DS.Adapter.html">DS.Adapter</a>
*/
export default DS.Adapter.extend({
/* Map of hashes for bulk operations */
_hashesToStore: EmberMap.create(),
/**
If you would like your adapter to use a custom serializer you can set the defaultSerializer property to be the name of the custom serializer.
[More info](http://emberjs.com/api/data/classes/DS.Adapter.html#property_defaultSerializer).
@property defaultSerializer
@type String
@default 'offline'
*/
defaultSerializer: 'offline',
/**
Database name for IndexedDB.
@property dbName
@type String
@default 'ember-flexberry-data'
*/
dbName: 'ember-flexberry-data',
/**
Instance of dexie service.
@property dexieService
@type Offline.DexieService
*/
dexieService: service('dexie'),
/**
Generate globally unique IDs for records.
@method generateIdForRecord
@param {DS.Store} store
@param {DS.Model} type
@param {Object} inputProperties
@return {String}
*/
generateIdForRecord: generateUniqueId,
/**
Clear tables in IndexedDB database, if `table` not specified, clear all tables.
@method clear
@param {String} [table] Table name.
@return {Promise}
*/
clear(table) {
let store = getOwner(this).lookup('service:store');
let dexieService = this.get('dexieService');
let db = dexieService.dexie(this.get('dbName'), store);
if (table) {
return dexieService.performQueueOperation(db, (db) => db.table(table).clear());
} else {
return RSVP.all(db.tables.map(table => dexieService.performQueueOperation(db, () => table.clear())));
}
},
/**
Delete IndexedDB database.
@method delete
@return {Promise}
*/
delete() {
return Dexie.delete(this.get('dbName'));
},
/**
The `findRecord()` method is invoked when the store is asked for a record that has not previously been loaded.
[More info](http://emberjs.com/api/data/classes/DS.Adapter.html#method_findRecord).
@method findRecord
@param {DS.Store} store
@param {DS.Model} type
@param {String|Integer} id
@return {Promise}
*/
findRecord(store, type, id) {
let dexieService = this.get('dexieService');
let db = dexieService.dexie(this.get('dbName'), store);
return dexieService.performOperation(db, (db) => db.table(type.modelName).get(id));
},
/**
The `findAll()` method is used to retrieve all records for a given type.
[More info](http://emberjs.com/api/data/classes/DS.Adapter.html#method_findAll).
@method findAll
@param {DS.Store} store
@param {DS.Model} type
@return {Promise}
*/
findAll(store, type) {
let dexieService = this.get('dexieService');
let db = dexieService.dexie(this.get('dbName'), store);
return dexieService.performOperation(db, (db) => db.table(type.modelName).toArray());
},
/**
Find multiple records at once if coalesceFindRequests is true.
[More info](http://emberjs.com/api/data/classes/DS.Adapter.html#method_findMany).
@method findMany
@param {DS.Store} store
@param {DS.Model} type
@param {Array} ids
@return {Promise}
*/
findMany(store, type, ids) {
let promises = A();
let records = A();
let addRecord = (record) => {
records.pushObject(record);
};
for (let i = 0; i < ids.length; i++) {
promises.pushObject(this.findRecord(store, type, ids[i]).then(addRecord));
}
return RSVP.all(promises).then(() => RSVP.resolve(records.compact())).catch(reason => RSVP.reject(reason));
},
/**
The `queryRecord()` method is invoked when the store is asked for a single record through a query object.
[More info](http://emberjs.com/api/data/classes/DS.Adapter.html#method_queryRecord).
@method queryRecord
@param {DS.Store} store
@param {DS.Model} type
@param {Object|QueryObject} query
@return {Promise}
*/
queryRecord(store, type, query) {
return this.query(store, type, query).then(records => new RSVP.resolve(records.data[0]));
},
/**
This method is called when you call `query` on the store.
[More info](http://emberjs.com/api/data/classes/DS.Adapter.html#method_query).
Supports {{#crossLink "Query.QueryObject"}}{{/crossLink}} instance or objects that look like this:
```javascript
{
...
<property to query>: <value to match>,
//and
<property to query>: <value to match>,
...
}
```
@method query
@param {DS.Store} store
@param {DS.Model} type
@param {Object|QueryObject} query
@return {Promise}
*/
query(store, type, query) {
let modelName = type.modelName;
let projection = this._extractProjectionFromQuery(modelName, type, query);
if (query && query.originType) {
delete query.originType;
}
let dexieService = this.get('dexieService');
let db = dexieService.dexie(this.get('dbName'), store);
let queryOperation = (db) => {
let queryObject = query instanceof QueryObject ? query : this._makeQueryObject(store, modelName, query, projection);
return new IndexedDBAdapter(db).query(store, queryObject);
};
return dexieService.performOperation(db, queryOperation);
},
/**
Implement this method in a subclass to handle the creation of new records.
[More info](http://emberjs.com/api/data/classes/DS.Adapter.html#method_createRecord).
@method createRecord
@param {DS.Store} store
@param {DS.Model} type
@param {DS.Snapshot} snapshot
@return {Promise}
*/
createRecord(store, type, snapshot) {
let dexieService = this.get('dexieService');
let db = dexieService.dexie(this.get('dbName'), store);
let hash = store.serializerFor(snapshot.modelName).serialize(snapshot, { includeId: true });
let createOperation = (db) => new RSVP.Promise((resolve, reject) => {
db.table(type.modelName).add(hash).then((id) => {
db.table(type.modelName).get(id).then((record) => {
resolve(record);
}).catch(reject);
}).catch(reject);
});
return dexieService.performQueueOperation(db, createOperation).then(() => this._createOrUpdateParentModels(store, type, hash));
},
/**
Implement this method in a subclass to handle the updating of a record.
[More info](http://emberjs.com/api/data/classes/DS.Adapter.html#method_updateRecord).
@method updateRecord
@param {DS.Store} store
@param {DS.Model} type
@param {DS.Snapshot} snapshot
@return {Promise}
*/
updateRecord(store, type, snapshot) {
let dexieService = this.get('dexieService');
let db = dexieService.dexie(this.get('dbName'), store);
let hash = store.serializerFor(snapshot.modelName).serialize(snapshot, { includeId: true });
let updateOperation = (db) => new RSVP.Promise((resolve, reject) => {
db.table(type.modelName).put(hash).then((id) => {
db.table(type.modelName).get(id).then((record) => {
resolve(record);
}).catch(reject);
}).catch(reject);
});
return dexieService.performQueueOperation(db, updateOperation).then(() => this._createOrUpdateParentModels(store, type, hash));
},
/**
Implement this method in a subclass to handle the deletion of a record.
[More info](http://emberjs.com/api/data/classes/DS.Adapter.html#method_deleteRecord).
@method deleteRecord
@param {DS.Store} store
@param {DS.Model} type
@param {DS.Snapshot} snapshot
@return {Promise}
*/
deleteRecord(store, type, snapshot) {
let promises = A();
let dexieService = this.get('dexieService');
let db = dexieService.dexie(this.get('dbName'), store);
this._deleteDetailModels(dexieService, db, promises, snapshot.record);
promises.pushObject(dexieService.performQueueOperation(db, (db) => db.table(type.modelName).delete(snapshot.id)));
return RSVP.all(promises).then(() => {
return this._deleteParentModels(store, type, snapshot.id);
});
},
/* jshint unused:vars */
/* eslint-disable no-unused-vars */
deleteAllRecords(store, modelName, filter) {
// TODO Implement the method of removing all objects.
assert('Unsupported this metod in offline');
},
/* eslint-enable no-unused-vars */
/* jshint unused:true */
/**
Create record if it does not exist, or update changed fields of record.
@method updateOrCreate
@param {DS.Store} store
@param {DS.Model} type
@param {DS.Snapshot} snapshot
@param {Object} fieldsToUpdate
@return {Promise}
*/
updateOrCreate(store, type, snapshot, fieldsToUpdate) {
let dexieService = this.get('dexieService');
let db = dexieService.dexie(this.get('dbName'), store);
let updateOrCreateOperation = (db) => new RSVP.Promise((resolve, reject) => {
db.table(type.modelName).get(snapshot.id).then((record) => {
if (!isNone(fieldsToUpdate) && record) {
if ($.isEmptyObject(fieldsToUpdate)) {
resolve();
} else {
let hash = store.serializerFor(snapshot.modelName).serialize(snapshot, { includeId: true });
for (let attrName in hash) {
if (hash.hasOwnProperty(attrName) && !fieldsToUpdate.hasOwnProperty(attrName)) {
delete hash[attrName];
}
}
return dexieService.performQueueOperation(db, (db) => db.table(type.modelName).update(snapshot.id, hash)).then(resolve, reject);
}
} else {
let hash = store.serializerFor(snapshot.modelName).serialize(snapshot, { includeId: true });
return dexieService.performQueueOperation(db, (db) => db.table(type.modelName).put(hash)).then(resolve, reject);
}
}).catch(reject);
});
return dexieService.performOperation(db, updateOrCreateOperation);
},
/**
Stores record's hash for performing bulk operaion with {{#crossLink "Adapter.Offline/bulkUpdateOrCreate:method"}} method.
@method addHashForBulkUpdateOrCreate
@param {DS.Store} store
@param {DS.Model} type
@param {DS.Snapshot} snapshot
@param {Object} fieldsToUpdate
@param {Boolean} syncDownTime
@return {Promise}
*/
addHashForBulkUpdateOrCreate(store, type, snapshot, fieldsToUpdate, syncDownTime) {
let _this = this;
let dexieService = this.get('dexieService');
let db = dexieService.dexie(this.get('dbName'), store);
let addHashForBulkOperation = (db) => new RSVP.Promise((resolve, reject) => {
db.table(type.modelName).get(snapshot.id).then((record) => {
if (!isNone(fieldsToUpdate) && record) {
if (!$.isEmptyObject(fieldsToUpdate) || syncDownTime) {
let hash = store.serializerFor(snapshot.modelName).serialize(snapshot, { includeId: true });
for (let attrName in hash) {
if (hash.hasOwnProperty(attrName) && !fieldsToUpdate.hasOwnProperty(attrName)) {
delete hash[attrName];
}
}
if (syncDownTime) {
hash.syncDownTime = new Date();
}
// Merge record with hash and store it to local store only if hash contains some changes for record.
let needChangeRecord = false;
for (let attrName in hash) {
if (hash.hasOwnProperty(attrName) && (!record[attrName] || record[attrName] !== hash[attrName])) {
needChangeRecord = true;
break;
}
}
if (needChangeRecord) {
merge(record, hash);
_this._storeHashForBulkOperation(type.modelName, record);
} else {
dexieService.set('queueSyncDownWorksCount', dexieService.get('queueSyncDownWorksCount') - 1);
}
} else {
dexieService.set('queueSyncDownWorksCount', dexieService.get('queueSyncDownWorksCount') - 1);
}
} else {
let hash = store.serializerFor(snapshot.modelName).serialize(snapshot, { includeId: true });
if (syncDownTime) {
hash.syncDownTime = new Date();
}
_this._storeHashForBulkOperation(type.modelName, hash);
}
resolve();
}).catch(reject);
});
return dexieService.performOperation(db, addHashForBulkOperation);
},
/**
Performing bulk operaion with previously stored hashes.
@method bulkUpdateOrCreate
@param {DS.Store} store
@param {Boolean} replaceIfExist If set to `true` then hashes will be replaced in store if they already exist
@param {Boolean} clearHashesOnTransactionFail If set to `true` then previously stored hashes will be cleared if transaction with bulk operations fails
@return {Promise}
*/
bulkUpdateOrCreate(store, replaceIfExist, clearHashesOnTransactionFail) {
let _this = this;
let dexieService = this.get('dexieService');
let db = dexieService.dexie(this.get('dbName'), store);
let numberOfRecordsToStore = 0;
let bulkUpdateOrCreateOperation = (db) => new RSVP.Promise((resolve, reject) => {
if (_this._hashesToStore.size === 0) {
resolve();
} else {
let tableNames = _this._hashesToStore._keys.toArray();
db.transaction('rw', tableNames, () => {
for (let i = 0; i < tableNames.length; i++) {
let tableName = tableNames[i];
let arrayOfHashes = _this._hashesToStore.get(tableName);
numberOfRecordsToStore += arrayOfHashes ? arrayOfHashes.length : 0;
if (replaceIfExist) {
db.table(tableName).bulkPut(arrayOfHashes ? arrayOfHashes : {});
} else {
db.table(tableName).bulkAdd(arrayOfHashes ? arrayOfHashes : {});
}
}
}).then(() => {
dexieService.set('queueSyncDownWorksCount', dexieService.get('queueSyncDownWorksCount') - numberOfRecordsToStore);
_this._hashesToStore.clear();
resolve();
}).catch((err) => {
if (clearHashesOnTransactionFail) {
warn('Some data loss while performing sync down records!',
false,
{ id: 'ember-flexberry-data-debug.offline.sync-down-data-loss' });
dexieService.set('queueSyncDownWorksCount', dexieService.get('queueSyncDownWorksCount') - numberOfRecordsToStore);
_this._hashesToStore.clear();
}
reject(err);
});
}
});
return dexieService.performQueueOperation(db, bulkUpdateOrCreateOperation);
},
/**
This method is not implemented.
@method batchUpdate
*/
batchUpdate() {
throw new Error('Not implemented.');
},
/**
A method to get array of models.
@method batchSelect
@param {DS.Store} store The store.
@param {Query} queries Array of Flexberry Query objects.
@return {Promise} A promise that fulfilled with an array of query responses.
*/
batchSelect(store, queries) {
const promises = queries.map(query => store.query(query.modelName, query));
return RSVP.all(promises).then(result => A(result));
},
/**
Stores hash for performing bulk operaion into map.
@method _storeHashForBulkOperation
@param {String} modelName
@param {Object} hash
@private
*/
_storeHashForBulkOperation(modelName, hash) {
let arrayOfHashes = this._hashesToStore.get(modelName);
if (!arrayOfHashes) {
arrayOfHashes = [];
}
arrayOfHashes.push(hash);
this._hashesToStore.set(modelName, arrayOfHashes);
},
/**
Makes {{#crossLink "Query.QueryObject"}}{{/crossLink}} out of queries that look like this:
{
<property to query>: <value to match>,
...
}.
@method _makeQueryObject
@param store Store used for making query
@param {String} modelName The name of the model type.
@param {Object} query Query parameters.
@param {Object|String} [projection] Projection for query.
@return {QueryObject} Query object for IndexedDB adapter.
@private
*/
_makeQueryObject(store, modelName, query, projection) {
let builder = new QueryBuilder(store, modelName);
if (projection && isObject(projection) && (projection.projectionName)) {
builder.selectByProjection(projection.projectionName);
} else if (projection && typeof projection === 'string') {
builder.selectByProjection(projection);
}
let predicates = [];
for (let property in query) {
const queryValue = query[property] instanceof Date ? query[property].toString() : query[property];
// I suppose it's possible to encounter problems when queryValue will have 'Date' type...
predicates.push(new SimplePredicate(property, FilterOperator.Eq, queryValue));
}
if (predicates.length === 1) {
builder.where(predicates[0]);
} else if (predicates.length > 1) {
let cp = new ComplexPredicate(Condition.And, predicates[0], predicates[1]);
for (let i = 2; i < predicates.length; i++) {
cp = cp.and(predicates[i]);
}
builder.where(cp);
}
let queryObject = builder.build();
if (isEmpty(queryObject.projectionName)) {
// Now if projection is not specified then only 'id' field will be selected.
queryObject.select = [];
}
return queryObject;
},
/**
Retrieves projection from query and returns it.
Retrieved projection removes from the query.
@method _extractProjectionFromQuery
@param {String} modelName The name of the model type.
@param {subclass of DS.Model} typeClass Model type.
@param {Object} [query] Query parameters.
@param {String} [query.projection] Projection name.
@return {Object} Extracted projection from query or null
if projection is not set in query.
@private
*/
_extractProjectionFromQuery: function(modelName, typeClass, query) {
if (query && query.projection) {
let proj = query.projection;
if (typeof query.projection === 'string') {
let projName = query.projection;
proj = typeClass.projections.get(projName);
}
delete query.projection;
return proj;
}
// If using Query Language
if (query && query instanceof QueryObject && !isNone(query.projectionName)) {
let proj = typeClass.projections.get(query.projectionName);
return proj;
}
return null;
},
_createOrUpdateParentModels(store, type, record) {
let _this = this;
let parentModelName = type._parentModelName;
if (parentModelName) {
let information = new Information(store);
let newHash = {};
merge(newHash, record);
for (let attrName in newHash) {
if (newHash.hasOwnProperty(attrName) && !information.isExist(parentModelName, attrName)) {
delete newHash[attrName];
}
}
let dexieService = _this.get('dexieService');
let db = dexieService.dexie(_this.get('dbName'), store);
return dexieService.performQueueOperation(db, (db) => db.table(parentModelName).put(newHash)).then(() =>
_this._createOrUpdateParentModels(store, store.modelFor(parentModelName), record));
} else {
return RSVP.resolve();
}
},
_deleteParentModels(store, type, id) {
let _this = this;
let parentModelName = type._parentModelName;
if (parentModelName) {
let dexieService = _this.get('dexieService');
let db = dexieService.dexie(_this.get('dbName'), store);
return dexieService.performQueueOperation(db, (db) => db.table(parentModelName).delete(id)).then(() => {
return _this._deleteParentModels(store, store.modelFor(parentModelName), id);
});
} else {
return RSVP.resolve();
}
},
/**
Forming promises for deleting current record and its details.
@method _deleteDetailModels
@param {Offline.DexieService} dexieService Instance of dexie service.
@param {IndexedDB} db Instance of current IndexedDB database.
@param {Promise} promises Promises on deleting records.
@param {DS.Model} record Current record that should be deleted with details.
@private
*/
_deleteDetailModels(dexieService, db, promises, record) {
let _this = this;
record.eachRelationship((name, desc) => {
if (desc.kind === 'hasMany') {
record.get(name).forEach((relRecord) => {
_this._deleteDetailModels(dexieService, db, promises, relRecord);
promises.pushObject(dexieService.performQueueOperation(db, (db) => db.table(desc.type).delete(relRecord.id)));
});
}
});
}
});