UNPKG

ngn-data

Version:

Data modeling, stores, proxies, and utilities for NGN

917 lines (720 loc) 21.5 kB
/** * v0.1.11 generated on: Sat Jun 24 2017 01:20:07 GMT+0000 (UTC) * Copyright (c) 2014-2017, Ecor Ventures LLC. All Rights Reserved. See LICENSE (BSD3). */ 'use strict' class NgnDataStore extends NGN.EventEmitter { constructor (cfg) { cfg = cfg || {} super(cfg) Object.defineProperties(this, { _model: { enumerable: false, writable: true, configurable: false, value: NGN.coalesce(cfg.model) }, _data: NGN.private([]), _filters: NGN.private([]), _index: NGN.private(cfg.index || []), _created: NGN.private([]), _deleted: NGN.private([]), _loading: NGN.private(false), _softarchive: NGN.private([]), _proxy: NGN.private(cfg.proxy || null), allowDuplicates: NGN.public(NGN.coalesce(cfg.allowDuplicates, true)), errorOnDuplicate: NGN.const(NGN.coalesce(cfg.errorOnDuplicate, cfg.allowDuplicates, true)), autoRemoveExpiredRecords: NGN.privateconst(NGN.coalesce(cfg.autoRemoveExpiredRecords, true)), softDelete: NGN.privateconst(NGN.coalesce(cfg.softDelete, false)), softDeleteTtl: NGN.private(NGN.coalesce(cfg.softDeleteTtl, -1)), fifo: NGN.private(NGN.coalesce(cfg.FIFO, -1)), lifo: NGN.private(NGN.coalesce(cfg.LIFO, -1)), maxRecords: NGN.private(NGN.coalesce(cfg.maxRecords, -1)), minRecords: NGN.private(NGN.coalesce(cfg.minRecords, 0)), snapshotarchive: NGN.private(null) }) if (this.lifo > 0 && this.fifo > 0) { throw new Error('NGN.DATA.Store can be configured as FIFO or LIFO, but not both simultaneously.') } if (this.lifo > 0 || this.fifo > 0) { this.minRecords = 0 this.maxRecords = -1 } else { this.minRecords = this.minRecords < 0 ? 0 : this.minRecords } let obj = {} this._index.forEach(i => { obj[i] = [] }) this._index = obj const events = [ 'record.duplicate', 'record.create', 'record.update', 'record.delete', 'record.restored', 'record.purged', 'record.move', 'record.invalid', 'record.valid', 'clear', 'filter.create', 'filter.delete', 'index.create', 'index.delete' ] if (NGN.BUS) { events.forEach(eventName => { this.on(eventName, function () { let args = NGN.slice(arguments) args.shift() args.push(this) NGN.BUS.emit(eventName, args) }) }) } if (cfg.proxy) { if (this._proxy instanceof NGN.DATA.Proxy) { this._proxy.init(this) } else { throw new Error('Invalid proxy configuration.') } } } get model () { return this._model } replaceModel (modelFn) { this._model = modelFn } get proxy () { return this._proxy } set proxy (value) { if (!this._proxy && value instanceof NGN.DATA.Proxy) { this._proxy = value this._proxy.init(this) } } get snapshots () { return NGN.coalesce(this.snapshotarchive, []) } get history () { return this.changelog.reverse() } get data () { return this._data.map(function (record) { return record.data }) } get representation () { return this._data.map((record) => { return record.representation }) } get records () { return this.applyFilters(this._data) } get recordCount () { return this.applyFilters(this._data).length } get filtered () { let records = this.records return this._data.filter(function (record) { return records.filter(function (rec) { return rec.checksum === record.checksum }).length === 0 }) } get first () { if (this.records.length === 0) { return null } return this.records[0] } get last () { if (this.records.length === 0) { return null } return this.records[this.records.length - 1] } add (data, suppressEvent) { let record if (this.maxRecords > 0 && this._data.length + 1 > this.maxRecords) { throw new Error('Maximum record count exceeded.') } if (!(data instanceof NGN.DATA.Entity)) { try { data = JSON.parse(data) } catch (e) {} if (typeof data !== 'object') { throw new Error('Cannot add a non-object record.') } if (this.model) { record = new this.model(data) } else { record = data } } else { record = data } if (record.hasOwnProperty('_store')) { record._store = this } let dupe = this.isDuplicate(record) if (dupe) { this.emit('record.duplicate', record) if (!this.allowDuplicates) { if (this.errorOnDuplicate) { throw new Error('Cannot add duplicate record (allowDuplicates = false).') } return } } if (this.lifo > 0 && this._data.length + 1 > this.lifo) { this.remove(this._data.length - 1) } else if (this.fifo > 0 && this._data.length + 1 > this.fifo) { this.remove(0) } this.listen(record) this.applyIndices(record, this._data.length) this._data.push(record) !this._loading && this._created.indexOf(record) < 0 && this._created.push(record) if (!NGN.coalesce(suppressEvent, false)) { this.emit('record.create', record) } if (!record.valid) { this.emit('record.invalid', record) } return record } insertBefore (index, data, suppressEvent = false) { return this.insert(index, data, suppressEvent, 'before') } insertAfter (index, data, suppressEvent = false) { return this.insert(index + 1, data, suppressEvent, 'after') } insert (index, data, suppressEvent = false, position = 'after') { let record = this.add(data, true) if (record) { this.move(this._data.length - 1, index, position, false) if (!suppressEvent) { this.emit('record.create', record) } } return record } isDuplicate (record) { if (this._data.indexOf(record) >= 0) { return false } return this._data.filter(function (rec) { return rec.checksum === record.checksum }).length > 0 } listen (record) { record.on('field.update', delta => { this.updateIndice(delta.field, delta.old, delta.new, this._data.indexOf(record)) this.emit('record.update', record, delta) }) record.on('field.delete', delta => { this.updateIndice(delta.field, delta.old, undefined, this._data.indexOf(record)) this.emit('record.update', record, delta) }) record.on('field.invalid', () => { this.emit('record.invalid', record) }) record.on('field.valid', () => { this.emit('record.valid', record) }) record.on('expired', () => { if (!record.expired) { return } this.emit('record.expired', record) if (this.autoRemoveExpiredRecords) { const index = this.indexOf(record) if (index >= 0) { this.remove(record) } } }) } bulk (event, data) { this._loading = true let resultSet = [] data.forEach(record => { resultSet.push(this.add(record, true)) }) this._loading = false this._deleted = [] this._created = [] if (event !== null) { setTimeout(() => { this.emit(event || 'load', resultSet) resultSet = null }, 100) } else { resultSet = null } } load () { let array = Array.isArray(arguments[0]) ? arguments[0] : NGN.slice(arguments) this.bulk('load', array) } reload (data) { this.clear() let array = Array.isArray(arguments[0]) ? arguments[0] : NGN.slice(arguments) this.bulk('reload', array) } indexOf (record) { if (typeof record !== 'object' || (!(record instanceof NGN.DATA.Entity) && !record.checksum)) { return -1 } return this._data.findIndex(function (el) { return el.checksum === record.checksum }) } contains (record) { return this.indexOf(record) >= 0 } remove (data, suppressEvents) { let removedRecord = [] let dataIndex if (this.minRecords > 0 && this._data.length - 1 < this.minRecords) { throw new Error('Minimum record count not met.') } if (typeof data === 'number') { dataIndex = data } else if (data && data.checksum && data.checksum !== null || data instanceof NGN.DATA.Model) { dataIndex = this.indexOf(data) } else { let m = new this.model(data, true) dataIndex = this._data.findIndex(function (el) { return el.checksum === m.checksum }) } if (dataIndex < 0) { throw new Error('Record removal failed (record not found at index ' + (dataIndex || '').toString() + ').') } this._data[dataIndex].isDestroyed = true removedRecord = this._data.splice(dataIndex, 1) removedRecord.isDestroyed = true if (removedRecord.length > 0) { removedRecord = removedRecord[0] this.unapplyIndices(dataIndex) if (this.softDelete) { if (this.softDeleteTtl >= 0) { const checksum = removedRecord.checksum removedRecord.once('expired', () => { this.purgeDeletedRecord(checksum) }) removedRecord.expires = this.softDeleteTtl } this._softarchive.push(removedRecord) } if (!this._loading) { let i = this._created.indexOf(removedRecord) if (i >= 0) { i >= 0 && this._created.splice(i, 1) } else if (this._deleted.indexOf(removedRecord) < 0) { this._deleted.push(removedRecord) } } if (!NGN.coalesce(suppressEvents, false)) { this.emit('record.delete', removedRecord, dataIndex) } return removedRecord } return null } findArchivedRecord (checksum) { let index let record = this._softarchive.filter((record, i) => { if (record.checksum === checksum) { index = i return true } }) if (record.length !== 1) { let source try { source = NGN.stack.pop().path } catch (e) { source = 'Unknown' } console.warn('Cannot purge record. %c' + record.length + ' records found%c. Source: %c' + source, NGN.css, '', NGN.css) return null } return { index: index, record: record[0] } } purgeDeletedRecord (checksum) { const purgedRecord = this.findArchivedRecord(checksum) if (purgedRecord === null) { return null } this._softarchive.splice(purgedRecord.index, 1) this.emit('record.purged', purgedRecord.record) return purgedRecord.record } restore (checksum) { const purgedRecord = this.findArchivedRecord(checksum) if (purgedRecord === null) { return null } purgedRecord.record.removeAllListeners('expired') purgedRecord.record.expires = this.softDeleteTtl this.add(purgedRecord.record, true) this._softarchive[purgedRecord.index].removeAllListeners('expired') this._softarchive.splice(purgedRecord.index, 1) purgedRecord.record.isDestroyed = false this.emit('record.restored', purgedRecord.record) return purgedRecord.record } clear (purge = true) { if (!purge) { this._softarchive = this._data } else { this._softarchive = [] } this._data = [] Object.keys(this._index).forEach(index => { this._index[index] = [] }) this.emit('clear') } find (query, ignoreFilters) { if (this._data.length === 0) { return [] } let resultSet = [] switch (typeof query) { case 'function': resultSet = this._data.filter(query) break case 'number': resultSet = (query < 0 || query >= this._data.length) ? null : this._data[query] break case 'string': let indice = this.getIndices(this._data[0].idAttribute, query.trim()) if (indice !== null && indice.length > 0) { indice.forEach(index => { resultSet.push(this._data[index]) }) return resultSet } let recordSet = this._data.filter(function (record) { return (record[record.idAttribute] || '').toString().trim() === query.trim() }) resultSet = recordSet.length === 0 ? null : recordSet[0] break case 'object': if (query instanceof NGN.DATA.Model) { if (this.contains(query)) { return query } return null } let match = [] let noindex = [] let queryKeys = Object.keys(query) queryKeys.forEach(field => { let index = this.getIndices(field, query[field]) if (index) { match = match.concat(index || []) } else { field !== null && noindex.push(field) } }) match.filter(function (index, i) { return match.indexOf(index) === i }) if (noindex.length > 0) { resultSet = this._data.filter(function (record, i) { if (match.indexOf(i) >= 0) { return false } for (let x = 0; x < noindex.length; x++) { if (record[noindex[x]] !== query[noindex[x]]) { return false } } return true }) } resultSet = resultSet.concat(match.map(index => { return this._data[index] })).filter(function (record) { for (let y = 0; y < queryKeys.length; y++) { if (query[queryKeys[y]] !== record[queryKeys[y]]) { return false } } return true }) break default: resultSet = this._data } if (resultSet === null) { return null } if (!NGN.coalesce(ignoreFilters, false)) { this.applyFilters(resultSet instanceof Array ? resultSet : [resultSet]) } return resultSet } applyFilters (data) { if (this._filters.length === 0) { return data } this._filters.forEach(function (filter) { data = data.filter(filter) }) return data } addFilter (fn) { this._filters.push(fn) this.emit('filter.create', fn) } removeFilter (fn, suppressEvents) { suppressEvents = NGN.coalesce(suppressEvents, false) let removed = [] if (typeof fn === 'number') { removed = this._filters.splice(fn, 1) } else { removed = this._filters.splice(this._filters.indexOf(fn), 1) } if (removed.length > 0 && !suppressEvents) { this.emit('filter.delete', removed[0]) } } clearFilters (suppressEvents) { suppressEvents = NGN.coalesce(suppressEvents, false) if (suppressEvents) { this._filters = [] return } while (this._filters.length > 0) { this.emit('filter.delete', this._filters.pop()) } } deduplicate (suppressEvents) { suppressEvents = NGN.coalesce(suppressEvents, true) let records = this.data.map(function (rec) { return JSON.stringify(rec) }) let dupes = [] records.forEach((record, i) => { if (records.indexOf(record) < i) { dupes.push(this.find(i)) } }) dupes.forEach(duplicate => { this.remove(duplicate) }) } sort (fn) { if (typeof fn === 'function') { this.records.sort(fn) } else if (typeof fn === 'object') { let functionKeys = Object.keys(fn) this._data.sort(function (a, b) { for (let i = 0; i < functionKeys.length; i++) { if (a.hasOwnProperty(functionKeys[i]) && !b.hasOwnProperty(functionKeys[i])) { return 1 } if (!a.hasOwnProperty(functionKeys[i]) && b.hasOwnProperty(functionKeys[i])) { return -1 } if (a[functionKeys[i]] !== b[functionKeys[i]]) { switch (fn[functionKeys[i]].toString().trim().toLowerCase()) { case 'asc': if (typeof a.fields[functionKeys[i]] === 'string') { return a[functionKeys[i]].localeCompare(b[functionKeys[i]]) } return a[functionKeys[i]] > b[functionKeys[i]] ? 1 : -1 case 'desc': return a[functionKeys[i]] < b[functionKeys[i]] ? 1 : -1 default: if (typeof fn[functionKeys[i]] === 'function') { return fn[functionKeys[i]](a, b) } return 0 } } } return 0 }) } this.reindex() } createIndex (field, suppressEvents) { if (!this.model.hasOwnProperty(field)) { console.warn('The store\'s model does not contain a data field called %c' + field + '%c.', NGN.css, '') } let exists = this._index.hasOwnProperty(field) this._index[field] = this._index[field] || [] if (!NGN.coalesce(suppressEvents, false) && !exists) { this.emit('index.created', { field: field, store: this }) } } deleteIndex (field, suppressEvents) { if (this._index.hasOwnProperty(field)) { delete this._index[field] if (!NGN.coalesce(suppressEvents, false)) { this.emit('index.created', { field: field, store: this }) } } } clearIndices () { Object.keys(this._index).forEach(key => { this._index[key] = [] }) } deleteIndexes (suppressEvents) { suppressEvents = NGN.coalesce(suppressEvents, true) Object.keys(this._index).forEach(key => { this.deleteIndex(key, suppressEvents) }) } applyIndices (record, number) { let indexes = Object.keys(this._index) if (indexes.length === 0) { return } indexes.forEach(field => { if (record.hasOwnProperty(field)) { let values = this._index[field] for (let i = 0; i < values.length; i++) { if (values[i][0] === record[field]) { this._index[field][i].push(number) return } } this._index[field].push([record[field], number]) } }) } unapplyIndices (num) { Object.keys(this._index).forEach(field => { const i = this._index[field].indexOf(num) if (i >= 0) { this._index[field].splice(i, 1) } }) } updateIndice (field, oldValue, newValue, num) { if (!this._index.hasOwnProperty(field) || oldValue === newValue) { return } let ct = 0 for (let i = 0; i < this._index[field].length; i++) { let value = this._index[field][i][0] if (value === oldValue) { this._index[field][i].splice(this._index[field][i].indexOf(num), 1) ct++ } else if (newValue === undefined) { ct++ } else if (value === newValue) { this._index[field][i].push(num) this._index[field][i].shift() this._index[field][i].sort() this._index[field][i].unshift(value) ct++ } if (ct === 2) { return } } } getIndices (field, value) { if (!this._index.hasOwnProperty(field)) { return null } let indexes = this._index[field].filter(function (dataArray) { return dataArray.length > 0 && dataArray[0] === value }) if (indexes.length === 1) { indexes[0].shift() return indexes[0] } return [] } move (source, target, suppressEvent = false) { if (source === undefined) { console.warn('Cannot move record. No source specified.') return } if (target === undefined) { console.warn('Cannot move record. No target specified.') return } source = this.getRecordIndex(source) target = this.getRecordIndex(target) if (source === target) { return } this._data.splice(target, 0, this._data.splice(source, 1)[0]) if (!suppressEvent) { this.emit('record.move', { oldIndex: source, newIndex: target, record: this._data[target] }) } this.reindex() } getRecordIndex (value) { if (value === undefined) { console.warn('No argument passed to getRecordIndex().') return null } if (typeof value === 'number') { if (value < 0 || value >= this._data.length) { console.warn('%c' + value + '%c out of bounds.', NGN.css, '') return null } return value } else if (typeof value === 'string') { let id = value value = this.find(id) if (!value) { console.warn('%c' + id + '%c does not exist or cannot be found in the store.', NGN.css, '') return null } } return this.indexOf(value) } reindex () { this.clearIndices() this._data.forEach((record, index) => { this.applyIndices(record, index) }) } snapshot () { this.snapshotarchive = NGN.coalesce(this.snapshotarchive, []) let dataset = { timestamp: (new Date()).toJSON(), checksum: NGN.DATA.util.checksum(JSON.stringify(this.data)).toString(), modelChecksums: this.data.map((item) => { return NGN.DATA.util.checksum(JSON.stringify(item)).toString() }), data: this.data } this.snapshotarchive.unshift(dataset) this.emit('snapshot', dataset) return dataset } clearSnapshots () { this.snapshotarchive = null } } NGN.DATA.Store = NgnDataStore