ngn-data
Version:
Data modeling, stores, proxies, and utilities for NGN
917 lines (720 loc) • 21.5 kB
JavaScript
/**
* 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