diff --git a/src/cascade.js b/src/cascade.js new file mode 100644 index 0000000..0d77759 --- /dev/null +++ b/src/cascade.js @@ -0,0 +1,131 @@ +import has from 'underscore/modules/_has.js'; +import isFunction from 'underscore/modules/isFunction.js'; + +import { array } from 'helpers/array'; +import { log, warn } from 'helpers/log'; + +import { model } from './data'; +import { single } from './model'; + +export async function cascade(data, fields, expeditor) { + if (data.length === 0 || fields.length === 0) { + return; + } + + if (has(expeditor, 'children')) { + var previous = expeditor.children; + } + + var children = expeditor.children = []; + + if (previous) { + children._previous = previous; + } + + collect(data, fields, expeditor); + + var promises = children.map(expeditor => + expeditor.sequent() + ); + + var res = await Promise.all(promises); + + promises = children.map((expeditor, i) => { + if (expeditor.fields) { + return cascade(res[i], expeditor.fields, expeditor); + } + }); + + return Promise.all(promises); +} + +function collect(data, fields, expeditor) { + var m = model(expeditor.model); + + fields.forEach(field => { + var sequence = m._parse(field); + var step = sequence.find(step => step.field); + var index = sequence.indexOf(step); + var info = m._info(step.field); + + var tailField = sequence + .slice(index + 1) + .map(step => step.chunk) + .join('.'); + + if (info.model) { + propagate(data, step, info, tailField, expeditor); + } + else if (isFunction(m[step.field])) { + var calculated = + m[step.field + 'Field'] || + m[step.field].field; + + if (calculated) { + fields = array(calculated); + fields = single(fields); + log('expand "' + step.field + '" to', { fields }); + collect(data, fields, expeditor); + } + } + else if (m.fieldsMap[step.field] >= 0) { + // field belongs to model + } + else { + warn('model "' + m.aka + '"', + 'has no property "' + step.field + '"', + 'in field "' + field + '"'); + } + }); +} + +function propagate(data, step, info, tailField, expeditor) { + var children = expeditor.children; + + var k = [ + info.model, + step.field, + step.filter && step.filter.list.map(filter => filter.expression), + ]; + + var child = children[k]; + + if (!child) { + children[k] = child = spawn(data, step, info, expeditor); + children.push(child); + } + + if (tailField) { + child.fields.push(tailField); + } +} + +function spawn(data, step, info, expeditor) { + var fields = []; + var params = {}; + + params[info.index] = data.map(row => row[info.field]); + + if (step.filter) { + step.filter.list.forEach(filter => { + if (filter.operator === '=' && filter.value.indexOf('..') === -1) { + params[filter.field] = filter.value.split(','); + } + else { + fields.push(filter.field); + } + }); + } + + return expeditor.spawn({ + field: step.field, + nullable: step.nullable, + + inversed: [ info.inverse || expeditor.model ].concat(expeditor.inversed), // copy + index: info.index, + model: info.model, + + fields, + params, + }); +} diff --git a/src/collection.js b/src/collection.js new file mode 100644 index 0000000..2f9e065 --- /dev/null +++ b/src/collection.js @@ -0,0 +1,149 @@ +import { nativeIsArray, nativeKeys } from 'underscore/modules/_setup.js'; + +import { applyOwnIf } from 'helpers/apply'; +import { isObject } from 'helpers/is'; +import { log, raise } from 'helpers/log'; +import { measure } from 'helpers/measure'; + +import { collection, model } from './data'; +import { Index } from './index'; + +export function register(name, records) { + if (!(c = collection(name, !!records))) { + var m = model(name); + var c = new Collection(m.aka); + + collection(m.name, c); + collection(m.aka, c); + } + + if (records) { + c.splice(records); + } + + return c; +} + +export class Collection { + constructor(name) { + this.indexes = {}; + this.index(''); + + if (name) { + this.model = name; + } + + if (this.initialize) { + this.initialize(name); + } + } + + index(...keys) { + var k = keys.length < 2 ? keys[0] || '' : keys.join('-'); + var i = this.indexes[k]; + var m = model(this.model); + + if (!i) { + keys = k.split('-'); + i = this.indexes[k] = new Index(keys); + + if (k) { + measure('create index in "' + this.model + '" for keys ' + keys, () => { + this.find().forEach(i.register, i); + }); + } + + if (m) { + var onetime = keys.some(k => { + var step = m._parse(k).find(step => step.field); + var info = m._info(step.field); + + // one-to-many relation + return info.model && info.index !== 'id'; + }); + + if (onetime) { + log(this, 'one-time', { index: i }); + delete this.indexes[k]; + } + } + } + + return i; + } + + splice() { + measure('splice "' + this.model + '"', this._splice, this, arguments); + } + + _splice(records, doRemove) { + var m = model(this.model); + var i = this.index(m && m.origin || 'id'); + + var record = records[0]; + var isTuple = nativeIsArray(record); + var isRecord = m && record instanceof m.constructor; + + for (var j = 0; j < records.length; j++) { + record = records[j]; + + if (m) { + if (isTuple || !isRecord) { + record = records[j] = m.create(record); + } + } + + var values = i.records(record); + var exist = values && values[0]; + + if (exist && !isTuple && !isRecord) { + applyOwnIf(record, exist); + } + + if (doRemove) { + if (exist) { + records[j] = exist; + } + else { + records.splice(j--, 1); + } + } + + for (var k in this.indexes) { + if (exist) { + this.indexes[k].unregister(exist); + } + + if (!doRemove) { + this.indexes[k].register(record); + } + } + } + } + + find(params = {}, buffer) { + if (!isObject(params)) { + params = { id: params }; + } + + var keys = nativeKeys(params); + + return this + .index(...keys) + .select(params, buffer); + } + + findOne(params) { + return this.find(params)[0]; + } + + findOrFail(params) { + var record = this.find(params)[0]; + + if (record) { + return record; + } + + raise('not found', this.model, 'using', JSON.stringify(params)); + } +} diff --git a/src/data.js b/src/data.js new file mode 100644 index 0000000..3e056a5 --- /dev/null +++ b/src/data.js @@ -0,0 +1,91 @@ +import has from 'underscore/modules/_has.js'; +import { nativeCreate } from 'underscore/modules/_setup.js'; +import noop from 'underscore/modules/noop.js'; + +import { applyOwn } from 'helpers/apply'; + +var collections = {}; +const models = {}; + +export const data = { + collections, + extras: {}, + indexes, + models, + similars: {}, + views: {}, +}; + +export const fetchRefId = { fetchRefId: true }; + +export const opt = { + akaRe: '', + classify: false, + extra: false, + keySorter: false, // (a, b) => a <=> b + request: noop, +}; + +export function init(config) { + return applyOwn(opt, config); +} + +export function fork() { + collections = data.collections = nativeCreate(collections); +} + +export function free() { + collections = data.collections = Object.getPrototypeOf(collections); +} + +/** + * 'set' = true - find in fork + * 'set' = falsy - find in collections + * 'set' = smth - save in current storage + */ +export function collection(name, set) { + if (set === true) { + if (!has(collections, name)) { + return; + } + } + else if (set) { + collections[name] = set; + } + + return collections[name]; +} + +export function model(name, set) { + if (set) { + models[name] = set; + } + + return models[name]; +} + +export function find(name, params, buffer) { + return collection(name).find(params, buffer); +} + +export function findOne(name, params) { + return collection(name).findOne(params); +} + +export function findOrFail(name, params) { + return collection(name).findOrFail(params); +} + +export function index(name, ...keys) { + return collection(name).index(...keys); +} + +function indexes() { + var hash = {}; + + for (var name in collections) { + hash[name] = collections[name].indexes; + } + + return hash; +} diff --git a/src/expeditor.js b/src/expeditor.js new file mode 100644 index 0000000..562b482 --- /dev/null +++ b/src/expeditor.js @@ -0,0 +1,193 @@ +import noop from 'underscore/modules/noop.js'; + +import { applyOwn, applyTo } from 'helpers/apply'; +import { indexBy } from 'helpers/arrayBy'; +import { empty } from 'helpers/empty'; +import { log, raise } from 'helpers/log'; +import { uniq } from 'helpers/uniq'; + +import { cascade } from './cascade'; +import { model } from './data'; +import { Loader, addParam } from './loader'; + +export class Expeditor { + constructor(o) { + this.aborted = o.aborted || noop; + this.inversed = o.inversed || [ 'id' ]; + this.model = model(o.model).aka; + + if (o.fields) { + this.fields = uniq(o.fields); + } + + for (var field in o.params) { + addParam(field, o.params[field], this); + } + + applyTo(this, o, '_parent', 'field', 'index', 'name', 'nullable', 'range'); + this.names(); + + if (this.initialize) { + this.initialize(o); + } + } + + names() { + if ( + !(this.names = this.getNames()) || + !this.names.ymd && !this.names.edge + ) { + this.names = null; + delete this.range; + return; + } + + // find nearest range + this.bubble(expeditor => this.range = expeditor.range); + + if (!this.range && empty(this.params)) { + raise(this, '"' + this.model + '" is timebased', + 'and must be fetched with params or range', { expeditor: this }); + } + } + + getNames() { + return names(model(this.model).fieldsMap); + } + + spawn(o) { + return new Expeditor(applyOwn({ + _parent: this, + aborted: this.aborted, + name: this.name, + range: this.expanded, + }, o)); + } + + bubble(fn, buffer) { + for (var expeditor = this; expeditor; expeditor = expeditor._parent) { + if (fn && fn(expeditor, buffer) || !expeditor._parent) { + return buffer || expeditor; + } + } + } + + isRoot() { + return !this._parent; + } + + async sequent(data) { + if (this.aborted()) { + return []; + } + + data = await this.load(data); + data = this.filter(data); + data = this.ranger(data); + this.expand(data); + await this.cascade(data); + + return data; + } + + load(data) { + log(this, 'load "' + this.model + '"', this.params, { expeditor: this }); + + return new Loader({ + model: this.model, + names: this.names, + params: this.params, + range: this.range, + }) + .load(this, data); + } + + filter(data) { + // filter extra only in root request + if (this.isRoot()) { + data = data.filter(row => !row.extra); + } + + return data; + } + + isRanged() { + return Boolean(this.range && this.names && this.names.edge); + } + + ranger(data) { + if (this.isRanged()) { + data = data.filter(this._ranger, this); + } + + return data; + } + + _ranger(row) { + var min = this.range[0]; + var max = this.range[1]; + + var begin = row[this.names.edge[0]]; + var end = row[this.names.edge[1]]; + + return (!begin || begin <= max) && (!end || min <= end); + } + + expand(data) { + if (this.isRanged() && !this.expanded) { + this.expanded = [ + this._expand('shift', 0, data), + this._expand('pop', 1, data), + ]; + } + } + + _expand(method, i, data) { + var k = this.names.edge[i]; + var v = this.range[i]; + + var hash = indexBy(data, k, k); + + hash[v] = v; + + var values = Object.values(hash); + + while (values.length > 0) { + if ((v = values[method]())) { + return v; + } + } + + raise(this, '"' + this.model + '" expand require range value', { expeditor: this }); + } + + cascade(data) { + if (this.fields && this.isRoot() && !this.aborted()) { + log(this, 'dependencies', { fields: this.fields }, { expeditor: this }); + return cascade(this.data = data, this.fields, this); + } + } +} + +const aymd = [ 'year', 'month', 'day' ]; +const aym = [ 'year', 'month' ]; +const ay = [ 'year' ]; +const abe = [ 'begin', 'end' ]; + +function names(fieldsMap) { + return { + ymd: + fieldsMap.year >= 0 ? // eslint-disable-line no-nested-ternary + fieldsMap.month >= 0 ? // eslint-disable-line no-nested-ternary + fieldsMap.day >= 0 ? + aymd : + aym : + ay : + false, + edge: + fieldsMap.begin >= 0 && + fieldsMap.end >= 0 ? + abe : + false, + }; +} diff --git a/src/extra.js b/src/extra.js new file mode 100644 index 0000000..cf3c465 --- /dev/null +++ b/src/extra.js @@ -0,0 +1,149 @@ +import { array } from 'helpers/array'; +import { indexBy } from 'helpers/arrayBy'; +import { warn } from 'helpers/log'; +import { ns } from 'helpers/ns'; + +import { collection, data, fetchRefId, model, opt } from './data'; + +const extras = data.extras; + +export async function extra(expeditor) { + // skip nullable field + // skip root request + if (expeditor.nullable || expeditor.isRoot()) { + return; + } + + // find absent records + var ids = plans(expeditor); + + if (!ids || ids.length === 0) { + return; + } + + var field = fields(expeditor).join('.'); + var top = expeditor.bubble(); + + var k = [ field, ids ].join('/'); + var n = ns(extras, top.model); + + // fetch extra once + if (k in n) { + return n[k]; + } + + warn('extra "' + top.model + '"', { [field]: ids }, { expeditor }); + + if ((n[k] = opt.extra)) { + n[k] = opt.extra(top.model, ids, field); + await register(await n[k], expeditor); + n[k] = null; + } +} + +function fields(expeditor) { + return expeditor + .bubble((expeditor, buffer) => { + buffer.unshift(expeditor.field); + }, []) + .slice(1); +} + +function plans(expeditor) { + var ids = absent(expeditor); + + // skip full data + if (!ids || ids.length === 0) { + return; + } + + warn('extra "' + expeditor.model + '"', { absent: ids.slice() }); + + var field = fields(expeditor); + var top = expeditor.bubble(); + var map = {}; + + if (expeditor.index !== 'id') { + field = field.slice(0, -1); + } + + field = field.join('.'); + + // reduce source records poined to the same absent target record + top.data.some(row => { + var source = field ? + row.query(field, fetchRefId) : + [ row.id ]; + + source.forEach((id, i) => { + if ((i = ids.indexOf(id)) !== -1) { + ids.splice(i, 1); + map[row.id] = row.id; + } + }); + + return ids.length === 0; + }); + + return Object.values(map); +} + +function absent(expeditor) { + var c = collection(expeditor.model); + + if (c) { + var a = expeditor.params[expeditor.index]; + var i = c.index(expeditor.index); + + // find absent records + return array(a).filter(v => + !i.contains(v) + ); + } +} + +function register(data, expeditor) { + if (!data || data.length === 0) { + return; + } + + var target = expeditor.model; + var m = model(target); + + data = data.map(row => + m.create(row) + ); + + if (expeditor.index === 'id') { + extrify(data); + collection(target).splice(data); + } + else if (opt.classify) { + return classify(data, target); + } +} + +async function classify(data, target) { + var ids = []; + + var hash = indexBy(data, (record) => + ids.push(record.id) && + record.id + ); + + var result = await opt.classify(target, ids); + + var valid = result.valid .map(id => hash[id]); + var invalid = result.invalid.map(id => hash[id]); + + extrify(invalid); + + collection(target).splice(valid); + collection(target).splice(invalid); +} + +function extrify(data) { + data.forEach(record => + record.extra = true + ); +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..8d96d0d --- /dev/null +++ b/src/index.js @@ -0,0 +1,100 @@ +import { nativeIsArray } from 'underscore/modules/_setup.js'; + +import { copyOwn } from 'helpers/copy'; +import { push } from 'helpers/push'; +import { remove } from 'helpers/remove'; + +import { fetchRefId, opt } from './data'; + +export class Index { + constructor(keys) { + this.data = {}; + this.keys = keys.slice(); + + if (opt.keySorter) { + this.keys.sort(opt.keySorter); + } + + if (this.initialize) { + this.initialize(keys); + } + } + + select(params, buffer = []) { + for (var k in params) { + if (nativeIsArray(a = params[k])) { + var plain = copyOwn(params); + + for (var j = 0, a; j < a.length; j++) { + plain[k] = a[j]; + this.select(plain, buffer); + } + + return buffer; + } + } + + if ((records = this.records(params))) { + for (var i = 0, records; i * max < records.length;) { + buffer.push(...records.slice(i * max, ++i * max)); + } + } + + return buffer; + } + + records(o) { + var data = this.data; + var keys = this.keys; + var len = keys.length; + + for (var i = 0; data && i < len; i++) { + var v = kv(o, keys[i]); + + data = data[v]; + } + + return data; + } + + contains(v) { + var data = this.data[v]; + + return data && data.length > 0; + } + + unregister(o) { + var records = this.records(o); + + if (records) { + remove(records, o); + } + } + + register(o) { + var data = this.data; + var keys = this.keys; + var len = keys.length - 1; + + for (var i = 0; i < len; i++) { + var v = kv(o, keys[i]); + + data = data[v] || (data[v] = {}); + } + + v = kv(o, keys[i]); + push(data, v, o); + } +} + +const max = 256 * 256; + +function kv(o, k) { + if (k) { + return o.query && !(o.fieldsMap[k] >= 0) ? + o.query(k, fetchRefId) : + o[k]; + } + + return k; +} diff --git a/src/loader.js b/src/loader.js new file mode 100644 index 0000000..5ec577f --- /dev/null +++ b/src/loader.js @@ -0,0 +1,219 @@ +import { nativeIsArray, nativeKeys } from 'underscore/modules/_setup.js'; + +import { applyTo } from 'helpers/apply'; +import { array } from 'helpers/array'; +import { copyOwn } from 'helpers/copy'; +import { ns } from 'helpers/ns'; +import { push } from 'helpers/push'; +import { transform } from 'helpers/transform'; + +import { cascade } from './cascade'; +import { collection, model } from './data'; +import { extra } from './extra'; +import { Request } from './request'; +import * as Similar from './similar'; + +export class Loader extends Request { + init(o) { + super.init(o); + + this.fields = o.fields || []; + this.model = model(o.model).aka; + this.params = o.params ? copyOwn(o.params) : {}; + + applyTo(this, o, 'names', 'range'); + } + + addField(field) { + if (field) { + push(this, 'fields', field, 'uniq'); + } + } + + addParam(field, value) { + addParam(field, value, this); + } + + async load(expeditor, data) { + if (this.promise) { + expeditor.warn('is already loading'); + } + + this.promise = this.doLoad(expeditor, data); + data = await this.promise; + this.promise = null; + + return data; + } + + async doLoad(expeditor, data) { + await Promise.all(this.purify()); + + if (data) { + data = this.filter(data, this.params); + + if (this.fields.length > 0) { + await this.cascade(data, expeditor); + data = this.filter(data, expeditor.params); + } + } + else { + // ensure data is loaded + await this.fetch(); + await this.extra(expeditor); + // fetch again local data (loaded by similar or collection.loader) + data = this.local(this.params); + + // load fields before filter data by query + if (this.fields.length > 0) { + // ensure params dependencies exists + await this.cascade(data, expeditor); + // fetch again local data (if load filters) + data = this.local(expeditor.params); + } + } + + return data; + } + + purify() { + var fields = nativeKeys(this.params); + var m = model(this.model); + + return transform(fields, (promises, field) => { + var [ f1, f2, f3 ] = m._parse(field).map(step => step.field); + var info = m._info(f1); + + if (m.fieldsMap[f1] >= 0) { + if (!f2) { + return; + } + + if (info.model && !f3 && !model(info.model)._info(f2).model) { + var loader = new Loader({ + model: info.model, + params: { [f2]: this.params[field] }, + }); + + var promise = loader.fetch() + .then(data => { + data = loader.local(loader.params); + this.addParam(f1, data.map(row => row.id)); + delete this.params[field]; + }); + + promises.push(promise); + return; + } + } + + this.addField(field); + delete this.params[field]; + }); + } + + async fetch() { + var partials = {}; + var promise, similar; + + if ((similar = Similar.find(this, partials))) { + promise = similar.promise; + } + else if ((similar = partials.similar)) { + Similar.add(this); + + await Promise.all([ + promise = this.request(similar.params), + similar.promise, // wait similar for extra + ]); + } + else { + Similar.add(this); + + promise = this.request(this.params); + } + + return promise; + } + + extra(expeditor) { + return extra(expeditor); + } + + cascade(data, expeditor) { + if (expeditor.isRoot()) { + expeditor.data = data; + } + + return cascade(data, this.fields, expeditor); + } + + local(params) { + var c = collection(this.model); + var data = []; + + if (c) { + params = this.expandRange(params); + + for (var i = 0; i < params.length; i++) { + c.find(params[i], data); + } + } + + return data; + } + + filter(data, params) { + var filtered = []; + + for (var i = 0; i < data.length; i++) { + if (filter(data[i], params)) { + filtered.push(data[i]); + } + } + + return filtered; + } +} + +function filter(row, params) { + for (var field in params) { + if (!validate(row, field, params[field])) { + return false; + } + } + + return true; +} + +function validate(row, field, value) { + if (!nativeIsArray(value)) { + return value == row.get(field); // eslint-disable-line eqeqeq + } + + for (var i = 0; i < value.length; i++) { + if (validate(row, field, value[i])) { + return true; + } + } +} + +export function addParam(field, value, dst) { + var params = ns(dst, 'params'); + var hash = {}; + + [ params[field], value ].forEach(a => { + array(a).forEach(v => { + if (v || v === 0 && field !== 'id') { + hash[v] = v; + } + }); + }); + + var values = Object.values(hash); + + // set [] to prevent dispatch with empty params + if (values.length > 0 || field === 'id') { + params[field] = values.length === 1 ? values[0] : values; + } +} diff --git a/src/model.js b/src/model.js new file mode 100644 index 0000000..fc485ce --- /dev/null +++ b/src/model.js @@ -0,0 +1,233 @@ +import { nativeCreate, nativeIsArray } from 'underscore/modules/_setup.js'; +import isFunction from 'underscore/modules/isFunction.js'; +import isString from 'underscore/modules/isString.js'; +import isUndefined from 'underscore/modules/isUndefined.js'; + +import { append } from 'helpers/append'; +import { applyOwn } from 'helpers/apply'; +import { copyOwn } from 'helpers/copy'; +import { memoize } from 'helpers/memoize'; +import { transform } from 'helpers/transform'; +import { uniq } from 'helpers/uniq'; + +import { fetchRefId, model, opt } from './data'; +import { doSequence, parse } from './query'; + +export function register(proto) { + proto = copyOwn(proto); + + var name = proto.name; + var aka = proto.aka = name.replace(opt.akaRe, ''); + + var idFields = + proto.idFields || + proto.key || + [ 'id' ]; + + var fieldsMap = transform(proto.fields, (o, f, i, a) => { + if (isString(f)) { + f = a[i] = { name: f }; + } + + if (proto.patchFields) { + applyOwn(f, proto.patchFields[f.name]); + } + + if (f.reference && !f.property) { + f.property = f.name; + } + + o[f.name] = i; + }, {}); + + if (!(fieldsMap.id >= 0) && idFields.length === 1) { + fieldsMap.id = fieldsMap[idFields[0]]; + } + + class Model extends Basic {} + + proto = applyOwn(Model.prototype, proto, { + origin: proto.key ? proto.key.join('-') : 'id', + idValues: idFields.slice(), + idFields, + fieldsMap, + _info: memoize(info), + _parse: memoize(parse), + }); + + delete proto.key; + Object.defineProperty(Model, 'name', { value: name }); + + model(name, proto); + model(aka, proto); + + return proto; +} + +export class Basic { + constructor(o) { + var m = this; + + if (nativeIsArray(o)) { + for (var i = 0, len = o.length; i < len; i++) { + this[m.fields[i].name] = o[i]; + } + } + else { + for (var k in o) { + if (m.fieldsMap[k] >= 0 || isFunction(m[k])) { + this[k] = o[k]; + } + } + } + + if (!this.id) { + this.id = id(this, m.idFields, m.idValues); + } + + if (this.initialize) { + this.initialize(o); + } + } + + create(o) { + return new this.constructor(o); + } + + produce(fields, dst) { + if (!dst) { + dst = nativeCreate(this); + } + + for (var i = 0; i < fields.length; i++) { + var field = + fields[i].name || + fields[i]; + + var v = dst[field] = this.get(field); + + if (isUndefined(v)) { + dst[field] = null; + } + } + + return dst; + } + + get(k) { + if (k.indexOf(' ') !== -1) { + return k.split(' ') + .map(map, this) + .filter(filter) + .join(' '); + } + + // get id of the instance if the last field is reference or collection + var values = this.query(k, fetchRefId); + + if (!nativeIsArray(values)) { + // query contains selector + return values; + } + + if (values.length > 1) { + return values + .filter(filter) + .join(', '); + } + + return values[0]; + } + + query(query, options) { + var sequence = this._parse(query); + + oneElArr[0] = this; + + return doSequence(oneElArr, sequence, options); + } +} + +const oneElArr = [ null ]; +const fieldRe = /[a-zA-Z]/; + +function id(dst, fields, values) { + if (fields.length > 1) { + for (var i = 0; i < fields.length; i++) { + values[i] = dst[fields[i]]; + } + + return values.join('-'); + } + + return dst[fields[0]]; +} + +function map(k) { + return fieldRe.test(k[0]) ? this.get(k) : k; +} + +function filter(v) { + return !isUndefined(v) && v !== ''; +} + +// Интерпретация результата: +// В исходной модели "this" в поле "field" содержится значение, +// которое можно найти в целевой модели "model" в ключе "index", +// а из целевой модели перейти обратно в исходную модель через поле "inverse" +function info(field) { + var property = this.fields.find(f => f.name === field || f.aka === field); + + if (property) { + field = property.name; + } + + if (property && property.reference) { + // Поле "field" присутствует в исходной модели. + // Пример query: поиск значения в справочнике + return { + field, + model: model(property.reference).aka, + index: 'id', + }; + } + + if ( + (m = model(field)) && + (property = m.fields.find(f => + model(f.reference) && + model(f.reference).aka === this.aka) + ) + ) { + var ids = m.idFields; + var name = property.name; + var m; + + // Поле "field" отсутствует в исходной модели, тогда "field" = целевая "model" + // Пример query: найти задания на объекте + return { + field: property.property, + inverse: name, + model: field, + index: ids && ids.length === 1 && ids[0] === name ? 'id' : name, + }; + } + + return { field }; +} + +export function single(fields) { + var single = []; + + fields.forEach(fields => { + if (fields) { + fields = fields + .split(' ') + .filter(f => fieldRe.test(f[0])); + + append(single, fields); + } + }); + + return uniq(single); +} diff --git a/src/query.js b/src/query.js new file mode 100644 index 0000000..91eb24b --- /dev/null +++ b/src/query.js @@ -0,0 +1,309 @@ +import has from 'underscore/modules/_has.js'; +import { nativeIsArray } from 'underscore/modules/_setup.js'; +import isFunction from 'underscore/modules/isFunction.js'; +import isUndefined from 'underscore/modules/isUndefined.js'; + +import { array } from 'helpers/array'; +import { uniq } from 'helpers/uniq'; + +import { collection } from './data'; + +export function doSequence(records, sequence, options) { + for (var i = 0; i < sequence.length; i++) { + var step = sequence[i]; + + if (step.field) { + records = doField(records, step, options); + } + + if (step.filter) { + records = records.filter(step.filter.fn); + } + + if ( + step.last && + options && options.fetchRefId && + records[0] && has(records[0], 'id') + ) { + // get id of the instance if the last field is reference or collection + // all records are models or no ones + for (var j = 0; j < records.length; j++) { + records[j] = records[j].id; + } + } + + if (step.selector) { + records = step.selector.fn(records); + records = array(records); + } + } + + return records; +} + +function doField(records, step, options) { + var next = []; + var info, c; + + for (var i = 0; i < records.length; i++) { + var record = records[i]; + var result = record[step.field]; + + if (isFunction(result)) { + result = result.apply(record, step.args); + } + else if (record._info && (info = record._info(step.field)).model) { + result = record[info.field]; + + if (result === 0 || result === null) { + continue; + } + + if ( + step.last && + options && options.fetchRefId && + info.field === step.field && + !step.filter + ) { + // get id of the instance if last field is reference + } + else { + (c = collection(info.model)) && + (result = c.index(info.index).data[result]); + } + } + + if (isUndefined(result)) { + // continue; + } + else if (nativeIsArray(result)) { + if (result.length > 0) { + next.push(...result); + } + } + else { + next.push(result); + } + } + + return next; +} + +const nonBracesRe = /[^()[\]{}]+/g; +const pairBracesRe = /\(\)|\[\]|\{\}/g; + +const fieldRe = /^\w+(?:@\w+)?/; +const argsRe = /^\(([\w,.-]+)\)/; +const selectorRe = /\{(\w+)}$/; + +export function parse(query) { + // remove all non-parentheses + var braces = query.replace(nonBracesRe, ''); + + // remove empty pairs + // eslint-disable-next-line curly + while (braces.length !== (braces = braces.replace(pairBracesRe, '')).length); + + if (braces.length !== 0) { + throw new Error('parentheses is not balanced in query: "' + query + '"'); + } + + var sequence = [ query ]; + + // split query by dot + for (var i = 0, depth = 0, prev = 0; i < query.length; i++) { + switch (query[i]) { + case '(': + case '[': + case '{': + depth++; + break; + + case ')': + case ']': + case '}': + depth--; + break; + + case '.': + if (depth === 0) { + j = sequence.length; + + sequence[j - 1] = query.slice(prev, i); + sequence[j] = query.slice(i + 1); + + prev = i + 1; + } + } + } + + // split chunk to field, (args), [filter], {selector}, "?" as nullable + for (var j = 0; j < sequence.length; j++) { + var chunk = sequence[j]; + var step = sequence[j] = { chunk }; + var field, args, selector; + + // field + if ((field = chunk.match(fieldRe))) { + step.field = field[0]; + chunk = chunk.slice(field[0].length); // remove field + } + + // (arguments) + if ((args = chunk.match(argsRe))) { + step.args = args[1].split(','); + chunk = chunk.slice(args[0].length); // remove arguments + } + + // nullable? + if (chunk[chunk.length - 1] === '?') { + step.nullable = true; + chunk = chunk.slice(0, -1); // remove flag + } + + // {selector} + if ((selector = chunk.match(selectorRe))) { + step.selector = parseSelector(selector[1]); + chunk = chunk.slice(0, -selector[0].length); // remove selector + } + + // [filter][filter]... + if (chunk) { + step.filter = chunk.slice(1, -1).split(']['); + step.filter = parseFilters(step.filter); + } + } + + // mark field is last in the query + for (var k = sequence.length - 1; k >= 0; k--) { + step = sequence[k]; + + if (step.field) { + step.last = true; + break; + } + } + + return sequence; +} + +function parseFilters(expressions) { + var list = expressions.map(parseFilter); + var len = list.length; + var fn = list[0].fn; + + if (len > 1) { + fn = function fn(record) { + for (var i = 0; i < len; i++) { + if (!list[i].fn(record)) { + return false; + } + } + + return true; + }; + } + + return { fn, list }; +} + +const filterRe = /([!=<>]+)([\w$.,]+)$/; + +function parseFilter(expression) { + // tail = + // (operator) = <= >= != < > + // (value | min..max | value1,value2,...) + var tail = expression.match(filterRe); + + if (!tail || tail.length !== 3) { + throw new Error('operator and value is required in filter: "' + expression + '"'); + } + + var field = expression.slice(0, tail.index); + var [ , operator, value ] = tail; + + var fn = makeFilter(field, operator, value); + + if (fn) { + return { fn, expression, field, operator, value }; + } + + throw new Error('operator "' + operator + '" is not recognized in filter: "' + expression + '"'); +} + +function makeFilter(field, operator, value) { + var negative = false; + var between, list, len; + + switch (operator) { + case '!=': + negative = true; + // falls through + + case '=': + if ((between = value.split('..')).length > 1) { + return (record) => { + var v = record.get(field); + var r = between[0] <= v && v <= between[1]; + + return negative ^ r; + }; + } + + list = value.split(','); + len = list.length; + + return (record) => { + var v = record.get(field); + var r = false; + + for (var i = 0; !r && i < len; i++) { + r = list[i] == v; // eslint-disable-line eqeqeq + } + + return negative ^ r; + }; + + case '>=': + return (record) => record.get(field) >= value; + + case '<=': + return (record) => record.get(field) <= value; + + case '>': + return (record) => record.get(field) > value; + + case '<': + return (record) => record.get(field) < value; + } +} + +function parseSelector(selector) { + var fn = selectors[selector]; + + if (fn) { + return { fn, selector }; + } +} + +const selectors = { + first: (values) => values[0], + last: (values) => values[values.length - 1], + max: (values) => values.length > 0 ? Math.max.apply(Math, values) : null, + min: (values) => values.length > 0 ? Math.min.apply(Math, values) : null, + sum, + avg: (values) => values.length > 0 ? sum(values) / values.length : null, + count: (values) => values.length || null, + unique: uniq, + uniq, +}; + +function sum(values) { + var sum = null; + + for (var i = 0; i < values.length; i++) { + sum += values[i]; + } + + return sum; +} diff --git a/src/request.js b/src/request.js new file mode 100644 index 0000000..584dfea --- /dev/null +++ b/src/request.js @@ -0,0 +1,117 @@ +import { nativeIsArray } from 'underscore/modules/_setup.js'; + +import { applyOwnIf, applyTo } from 'helpers/apply'; +import { array } from 'helpers/array'; +import { copyOwn } from 'helpers/copy'; + +import { register } from './collection'; +import { collection, opt } from './data'; + +export class Request { + constructor(o) { + this.init(o); + + if (this.initialize) { + this.initialize(o); + } + } + + init() { + // nop + } + + async request(params) { + if ((params = this.getParams(params))) { + var data = await opt.request(params); + + return this.register(data, params); + } + } + + getParams(params) { + if (params.id && (c = collection(this.model))) { + var i = c.index('id'); + var c; + + // skip fetch loaded instances + var id = array(params.id).filter(id => !i.contains(id)); + + // keep prev params in similar + params = applyOwnIf({ id }, params); + } + + for (var k in params) { + var v = params[k]; + + // prevent dispatch with empty param + if (nativeIsArray(v) && v.length === 0) { + return; + } + } + + params = this.expandRange(params); + + if (params.length === 0) { + return; + } + + params = params.length === 1 ? params[0] : params; + params = applyTo({ params }, this, 'model'); + + if (this.range && this.names.edge) { + params.names = this.names.edge; + params.range = this.range; + } + + return params; + } + + expandRange(params) { + // "names" without range is forbidden + if (!this.range || !(ymd = this.names.ymd)) { + return [ params ]; + } + + var span = ymd[ymd.length - 1]; + var format = 'YYYYMMDD'.slice(0, 2 + 2 * ymd.length); + var array = []; + var ymd; + + var minDate = moment(this.range[0], format); + var maxDate = moment(this.range[1], format).add(1, span); + + do { + var p = copyOwn(params); + + var v = { + year: minDate.year(), + month: minDate.month() + 1, + day: minDate.date(), + }; + + span = ymd.find((f, i, ymd) => + (p[f] = v[f]) && + ymd + .slice(i + 1) + .every(f => v[f] === 1) && + moment(minDate) + .add(1, f) + .isSameOrBefore(maxDate) + ); + + if (span) { + array.push(p); + minDate.add(1, span); + } + } + while (span); + + return array; + } + + register(data) { + if (data) { + register(this.model, data); + } + } +} diff --git a/src/similar.js b/src/similar.js new file mode 100644 index 0000000..8d141d0 --- /dev/null +++ b/src/similar.js @@ -0,0 +1,114 @@ +import { nativeIsArray } from 'underscore/modules/_setup.js'; + +import { applyOwnIf } from 'helpers/apply'; +import { empty } from 'helpers/empty'; +import { push } from 'helpers/push'; +import { remove as aRemove } from 'helpers/remove'; + +import { data } from './data'; + +const similars = data.similars; + +export function add(loader) { + push(similars, loader.model, loader); +} + +export function remove(loader) { + aRemove(similars[loader.model], loader); +} + +export function find(loader, o) { + var list = similars[loader.model]; + var partials, shrunken; + + for (var i = 0; list && i < list.length; i++) { + var candidate = list[i]; + + if (range(candidate, loader)) { + if ((shrunken = params(candidate, loader)) === true) { + return candidate; + } + + if (shrunken) { + (partials || (partials = [])).push({ + params: shrunken, + promise: candidate.promise, + weight: JSON.stringify(shrunken).length, + }); + } + } + } + + if (partials && partials[0]) { + // if (partials[1]) { + // partials.sort((a, b) => a.weight - b.weight); + // } + + o.similar = partials[0]; + } +} + +function range(candidate, loader) { + // candidate without range contains any request range + if (!candidate.range) { + return true; + } + + // skip less range + if (!loader.range) { + return false; + } + + var [ cmin, cmax ] = candidate.range; + var [ rmin, rmax ] = loader.range; + + return cmin <= rmin && rmax <= cmax; +} + +function params(candidate, loader) { + var outside, params; + + for (var k in candidate.params) { + var cv = candidate.params[k]; + var rv = loader.params[k]; + + // request wide set + if (!rv && rv !== 0) { + return false; + } + + if (!nativeIsArray(cv)) { + cv = [ cv ]; + } + + if (!nativeIsArray(rv)) { + rv = [ rv ]; + } + + var diff = {}; + + rv.forEach(rv => diff[rv] = rv); + cv.forEach(cv => delete diff[cv]); + + var missing = Object.values(diff); + + if (missing.length > 0) { + (params || (params = {}))[k] = missing; + + // non-crossed outside range + if (missing.length === rv.length) { + return false; + } + + // allow one param be outside range + if (outside) { + return false; + } + + outside = true; + } + } + + return empty(params) || + applyOwnIf(params, loader.params); +} diff --git a/src/view.js b/src/view.js new file mode 100644 index 0000000..4427699 --- /dev/null +++ b/src/view.js @@ -0,0 +1,322 @@ +import has from 'underscore/modules/_has.js'; +import { nativeCreate, nativeIsArray } from 'underscore/modules/_setup.js'; +import isFunction from 'underscore/modules/isFunction.js'; +import isString from 'underscore/modules/isString.js'; + +import { applyTo } from 'helpers/apply'; +import { array } from 'helpers/array'; +import { indexBy } from 'helpers/arrayBy'; +import { buffer } from 'helpers/buffer'; +import { debug, log, warn } from 'helpers/log'; +import { portion } from 'helpers/portion'; +import { push } from 'helpers/push'; +import { remove } from 'helpers/remove'; + +import { collection, data, model } from './data'; +import { Expeditor } from './expeditor'; +import { single } from './model'; + +var autoId = 0; +const views = data.views; + +export class View { + constructor(o) { + this.model = model(o.model).aka; + this.id = [ 'view', this.model, ++autoId ].join('-'); + + this.generation = 0; + this.dependencies = {}; + + views[this.id] = this; + + this.fields = [ 'id' ]; + this.annexes = []; + + this.addFields(o.fields); + this.addAnnexes(o.annexes); + + applyTo(this, o, 'name'); + + if (this.initialize) { + this.initialize(o); + } + } + + destroy() { + views[this.id] = null; + log(this, 'destroyed', this.destroyed = true); + } + + async load(params = {}) { + if (this.loading) { + warn(this, 'is already loading'); + } + + delete this.rawData; + delete this.data; + delete this.map; + + var expeditor = this.getExpeditor(params); + var data; + + try { + this.loading = true; + + data = this.rawData = // debug + await this.sequent(expeditor); + + data = this.data = // debug + await this.produce(expeditor, data); + } + finally { + this.loading = false; + } + + if (!expeditor.aborted()) { + this.complete(expeditor, data); + } + } + + getExpeditor(params, range) { + if (params.minYmd || params.maxYmd) { + range = range || [ + params.minYmd || params.maxYmd, + params.maxYmd || params.minYmd, + ]; + + delete params.minYmd; + delete params.maxYmd; + } + + var fields = single(this.fields); + + // skip annexes + this.annexes.forEach(annex => { + // field is calculated + remove(fields, annex.annex, 'all'); + // todo: add annex.path to fields + }); + + return new Expeditor({ + aborted: this.getAbortedFn(), + name: this.name, + model: this.model, + fields, + params, + range, + }); + } + + getAbortedFn() { + var generation = ++this.generation; + + return (message) => { + if (this.generation !== generation) { + var s = 'aborted'; + } + else if (this.destroyed) { + s = 'destroyed'; + } + else { + return; + } + + if (isString(message)) { + warn(this, message); + } + + return s; + }; + } + + sequent(expeditor, data) { + return expeditor.sequent(data); + } + + async produce(expeditor, data) { + var total = data.length; + var rows = new Array(total); + + await portion.call(this, 'apply rows', total, expeditor.aborted, (i) => { + rows[i] = this.applyRow(data[i]); + }); + + return rows; + } + + addFields(fields) { + array(fields).forEach(field => { + if (field) { + push(this, 'fields', field, 'uniq'); + } + }); + } + + addAnnexes(annexes) { + array(annexes).forEach(func => { + this.annexes.push( + isFunction(func) ? { func, scope: this } : func + ); + }); + } + + applyRow(src, dst) { + dst = src.produce(this.fields, dst); + + this.annexes.forEach(annex => { + annex.func.call(annex.scope, dst, annex); + }); + + return dst; + } + + complete(expeditor, data) { + this.expeditor = expeditor; + this.map = indexBy(data, 'id'); + + this.finalize(expeditor, data, 'refresh'); + } + + finalize(expeditor, data, event) { + log(this, 'finalize', event, { data, expeditor }); + + this.addDependency(expeditor); + this.monCollections(); + } + + addDependency(expeditor) { + var deps = this.dependencies; + + if (!isString(deps[expeditor.model])) { + push(deps, expeditor.model, expeditor.inversed); + } + + if (expeditor.children) { + expeditor.children.forEach(this.addDependency, this); + } + } + + monCollections() { + var deps = this.dependencies; + var v, c; + + for (var aka in deps) { + if (nativeIsArray(v = deps[aka])) { + if ((c = collection(aka))) { + var counter = v.map(inversed => inversed.length); + var shortest = Math.min.apply(Math, counter); + + shortest = counter.indexOf(shortest); + deps[aka] = v[shortest].join('.'); + this.monCollection(c); + } + else { + warn(this, 'monitor skip non-loaded collection', aka); + } + } + } + + log(this, 'monitor reverse fields', { dependencies: deps }); + } + + monCollection() { + // this.mon(c, 'change', this.applyChanges, this); + } + + applyChanges(record) { + if (nativeIsArray(record)) { + record.forEach(this.applyChanges, this); + } + else { + this.monBuffer(record); + } + } + + monBuffer(record) { + if (this.expeditor.aborted('buffer record in denied state')) { + return; + } + + var aka = record.aka; + var field = this.dependencies[aka]; + + if (!field) { + warn(this, 'buffer field is not found in dependencies', aka); + return; + } + + if (nativeIsArray(field)) { + warn(this, 'buffer field expected to be a string, array given for', aka); + return; + } + + debug(this, 'update', aka, record.id); + + record.query(field).forEach(id => { + buffer(this.id, id, this.processBuffer, this); + }); + } + + async processBuffer(ids) { + var expeditor = nativeCreate(this.expeditor); + + if (expeditor.aborted('process buffer in denied state')) { + return; + } + + var data = collection(this.model).find(ids); + + data = await this.sequent(expeditor, data); + data = await this.produce(expeditor, data); + + if (expeditor.aborted('update in denied state')) { + return; + } + + var filtered = indexBy(data, 'id'); + var rows = []; + + ids.forEach(id => { + var src = filtered[id]; + var dst = this.map[id]; + var changed; + + if (src) { + if (dst) { + // compare field values to make the decision "row is updated" + for (var f in dst) { + if (!(changed = has(dst, f))) { + break; + } + + if (dst[f] !== src[f]) { + break; + } + } + + debug(this, 'update row', id, changed ? 'changed' : 'skipped'); + + if (!changed) { + return; + } + } + else { + debug(this, 'add row', id); + } + + dst = this.map[id] = src; + } + + if (dst) { + rows.push(dst); + + if (!src) { + debug(this, 'remove row', id); + delete this.map[id]; + } + } + }); + + this.finalize(expeditor, rows, 'update'); + } +}