firetruss
Version:
Advanced data sync layer for Firebase and Vue.js
211 lines (176 loc) • 5.99 kB
JavaScript
import {escapeKey, unescapeKey, makePathMatcher} from './utils/paths.js';
import _ from 'lodash';
/* eslint-disable no-use-before-define */
const EMPTY_ANNOTATIONS = {};
Object.freeze(EMPTY_ANNOTATIONS);
export class Handle {
constructor(tree, path, annotations) {
this._tree = tree;
this._path = path.replace(/^\/*/, '/').replace(/\/$/, '') || '/';
if (annotations) {
this._annotations = annotations;
Object.freeze(annotations);
}
}
get $ref() {return this;}
get key() {
if (!this._key) this._key = unescapeKey(this._path.replace(/.*\//, ''));
return this._key;
}
get path() {return this._path;}
get _pathPrefix() {return this._path === '/' ? '' : this._path;}
get parent() {
return new Reference(this._tree, this._path.replace(/\/[^/]*$/, ''), this._annotations);
}
get annotations() {
return this._annotations || EMPTY_ANNOTATIONS;
}
child() {
if (!arguments.length) return this;
const segments = [];
for (const key of arguments) {
if (_.isNil(key)) return;
segments.push(escapeKey(key));
}
return new Reference(
this._tree, `${this._pathPrefix}/${segments.join('/')}`,
this._annotations
);
}
children() {
if (!arguments.length) return this;
const escapedKeys = [];
for (let i = 0; i < arguments.length; i++) {
const arg = arguments[i];
if (_.isArray(arg)) {
const mapping = {};
const subPath = this._pathPrefix + (escapedKeys.length ? `/${escapedKeys.join('/')}` : '');
const rest = _.slice(arguments, i + 1);
for (const key of arg) {
const subRef =
new Reference(this._tree, `${subPath}/${escapeKey(key)}`, this._annotations);
const subMapping = subRef.children.apply(subRef, rest);
if (subMapping) mapping[key] = subMapping;
}
return mapping;
}
if (_.isNil(arg)) return;
escapedKeys.push(escapeKey(arg));
}
return new Reference(
this._tree, `${this._pathPrefix}/${escapedKeys.join('/')}`, this._annotations);
}
peek(callback) {
return this._tree.truss.peek(this, callback);
}
match(pattern) {
return makePathMatcher(pattern).match(this.path);
}
test(pattern) {
return makePathMatcher(pattern).test(this.path);
}
isEqual(that) {
if (!(that instanceof Handle)) return false;
return this._tree === that._tree && this.toString() === that.toString() &&
_.isEqual(this._annotations, that._annotations);
}
belongsTo(truss) {
return this._tree.truss === truss;
}
}
export class Query extends Handle {
constructor(tree, path, spec, annotations) {
super(tree, path, annotations);
this._spec = this._copyAndValidateSpec(spec);
const queryTerms = _(this._spec)
.map((value, key) => `${key}=${encodeURIComponent(JSON.stringify(value))}`)
.sortBy()
.join('&');
this._string = `${this._path}?${queryTerms}`;
Object.freeze(this);
}
// Vue-bound
get ready() {
return this._tree.isQueryReady(this);
}
get constraints() {
return this._spec;
}
annotate(annotations) {
return new Query(
this._tree, this._path, this._spec, _.assign({}, this._annotations, annotations));
}
_copyAndValidateSpec(spec) {
if (!spec.by) throw new Error('Query needs "by" clause: ' + JSON.stringify(spec));
if (('at' in spec) + ('from' in spec) + ('to' in spec) > 1) {
throw new Error(
'Query must contain at most one of "at", "from", or "to" clauses: ' + JSON.stringify(spec));
}
if (('first' in spec) + ('last' in spec) > 1) {
throw new Error(
'Query must contain at most one of "first" or "last" clauses: ' + JSON.stringify(spec));
}
if (!_.some(['at', 'from', 'to', 'first', 'last'], clause => clause in spec)) {
throw new Error(
'Query must contain at least one of "at", "from", "to", "first", or "last" clauses: ' +
JSON.stringify(spec));
}
spec = _.clone(spec);
if (spec.by !== '$key' && spec.by !== '$value') {
if (!(spec.by instanceof Reference)) {
throw new Error('Query "by" value must be a reference: ' + spec.by);
}
let childPath = spec.by.toString();
if (!_.startsWith(childPath, this._path)) {
throw new Error(
'Query "by" value must be a descendant of target reference: ' + spec.by);
}
childPath = childPath.slice(this._path.length).replace(/^\/?/, '');
if (!_.includes(childPath, '/')) {
throw new Error(
'Query "by" value must not be a direct child of target reference: ' + spec.by);
}
spec.by = childPath.replace(/.*?\//, '');
}
Object.freeze(spec);
return spec;
}
toString() {
return this._string;
}
}
export class Reference extends Handle {
constructor(tree, path, annotations) {
super(tree, path, annotations);
Object.freeze(this);
}
get ready() {return this._tree.isReferenceReady(this);} // Vue-bound
get value() {return this._tree.getObject(this.path);} // Vue-bound
toString() {return this._path;}
annotate(annotations) {
return new Reference(this._tree, this._path, _.assign({}, this._annotations, annotations));
}
query(spec) {
return new Query(this._tree, this._path, spec, this._annotations);
}
set(value) {
this._checkForUndefinedPath();
return this._tree.update(this, 'set', {[this.path]: value});
}
update(values) {
this._checkForUndefinedPath();
return this._tree.update(this, 'update', values);
}
override(value) {
this._checkForUndefinedPath();
return this._tree.update(this, 'override', {[this.path]: value});
}
commit(updateFunction) {
this._checkForUndefinedPath();
return this._tree.commit(this, updateFunction);
}
_checkForUndefinedPath() {
if (this.path === '/undefined') throw new Error('Invalid path for operation: ' + this.path);
}
}
export default Reference;