frontend/table.js (148 lines of code) (raw):

const { OBJECT_ID, CONFLICTS } = require('./constants') const { isObject, copyObject } = require('../src/common') function compareRows(properties, row1, row2) { for (let prop of properties) { if (row1[prop] === row2[prop]) continue if (typeof row1[prop] === 'number' && typeof row2[prop] === 'number') { return row1[prop] - row2[prop] } else { const prop1 = '' + row1[prop], prop2 = '' + row2[prop] if (prop1 === prop2) continue if (prop1 < prop2) return -1; else return +1 } } return 0 } /** * A relational-style unordered collection of records (rows). Each row is an * object that maps column names to values. The set of rows is represented by * a map from UUID to row object. */ class Table { /** * This constructor is used by application code when creating a new Table * object within a change callback. */ constructor() { this.entries = Object.freeze({}) this.opIds = Object.freeze({}) Object.freeze(this) } /** * Looks up a row in the table by its unique ID. */ byId(id) { return this.entries[id] } /** * Returns an array containing the unique IDs of all rows in the table, in no * particular order. */ get ids() { return Object.keys(this.entries).filter(key => { const entry = this.entries[key] return isObject(entry) && entry.id === key }) } /** * Returns the number of rows in the table. */ get count() { return this.ids.length } /** * Returns an array containing all of the rows in the table, in no particular * order. */ get rows() { return this.ids.map(id => this.byId(id)) } /** * The standard JavaScript `filter()` method, which passes each row to the * callback function and returns all rows for which the it returns true. */ filter(callback, thisArg) { return this.rows.filter(callback, thisArg) } /** * The standard JavaScript `find()` method, which passes each row to the * callback function and returns the first row for which it returns true. */ find(callback, thisArg) { return this.rows.find(callback, thisArg) } /** * The standard JavaScript `map()` method, which passes each row to the * callback function and returns a list of its return values. */ map(callback, thisArg) { return this.rows.map(callback, thisArg) } /** * Returns the list of rows, sorted by one of the following: * - If a function argument is given, it compares rows as per * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Description * - If a string argument is given, it is interpreted as a column name and * rows are sorted according to that column. * - If an array of strings is given, it is interpreted as a list of column * names, and rows are sorted lexicographically by those columns. * - If no argument is given, it sorts by row ID by default. */ sort(arg) { if (typeof arg === 'function') { return this.rows.sort(arg) } else if (typeof arg === 'string') { return this.rows.sort((row1, row2) => compareRows([arg], row1, row2)) } else if (Array.isArray(arg)) { return this.rows.sort((row1, row2) => compareRows(arg, row1, row2)) } else if (arg === undefined) { return this.rows.sort((row1, row2) => compareRows(['id'], row1, row2)) } else { throw new TypeError(`Unsupported sorting argument: ${arg}`) } } /** * When iterating over a table, you get all rows in the table, in no * particular order. */ [Symbol.iterator] () { let rows = this.rows, index = -1 return { next () { index += 1 if (index < rows.length) { return {done: false, value: rows[index]} } else { return {done: true} } } } } /** * Returns a shallow clone of this object. This clone is used while applying * a patch to the table, and `freeze()` is called on it when we have finished * applying the patch. */ _clone() { if (!this[OBJECT_ID]) { throw new RangeError('clone() requires the objectId to be set') } return instantiateTable(this[OBJECT_ID], copyObject(this.entries), copyObject(this.opIds)) } /** * Sets the entry with key `id` to `value`. `opId` is the ID of the operation * performing this assignment. This method is for internal use only; it is * not part of the public API of Automerge.Table. */ _set(id, value, opId) { if (Object.isFrozen(this.entries)) { throw new Error('A table can only be modified in a change function') } if (isObject(value) && !Array.isArray(value)) { Object.defineProperty(value, 'id', {value: id, enumerable: true}) } this.entries[id] = value this.opIds[id] = opId } /** * Removes the row with unique ID `id` from the table. */ remove(id) { if (Object.isFrozen(this.entries)) { throw new Error('A table can only be modified in a change function') } delete this.entries[id] delete this.opIds[id] } /** * Makes this object immutable. This is called after a change has been made. */ _freeze() { Object.freeze(this.entries) Object.freeze(this.opIds) Object.freeze(this) } /** * Returns a writeable instance of this table. This instance is returned when * the table is accessed within a change callback. `context` is the proxy * context that keeps track of the mutations. */ getWriteable(context, path) { if (!this[OBJECT_ID]) { throw new RangeError('getWriteable() requires the objectId to be set') } const instance = Object.create(WriteableTable.prototype) instance[OBJECT_ID] = this[OBJECT_ID] instance.context = context instance.entries = this.entries instance.opIds = this.opIds instance.path = path return instance } /** * Returns an object containing the table entries, indexed by objectID, * for serializing an Automerge document to JSON. */ toJSON() { const rows = {} for (let id of this.ids) rows[id] = this.byId(id) return rows } } /** * An instance of this class is used when a table is accessed within a change * callback. */ class WriteableTable extends Table { /** * Returns a proxied version of the row with ID `id`. This row object can be * modified within a change callback. */ byId(id) { if (isObject(this.entries[id]) && this.entries[id].id === id) { const objectId = this.entries[id][OBJECT_ID] const path = this.path.concat([{key: id, objectId}]) return this.context.instantiateObject(path, objectId, ['id']) } } /** * Adds a new row to the table. The row is given as a map from * column name to value. Returns the objectId of the new row. */ add(row) { return this.context.addTableRow(this.path, row) } /** * Removes the row with ID `id` from the table. Throws an exception if the row * does not exist in the table. */ remove(id) { if (isObject(this.entries[id]) && this.entries[id].id === id) { this.context.deleteTableRow(this.path, id, this.opIds[id]) } else { throw new RangeError(`There is no row with ID ${id} in this table`) } } } /** * This function is used to instantiate a Table object in the context of * applying a patch (see apply_patch.js). */ function instantiateTable(objectId, entries, opIds) { const instance = Object.create(Table.prototype) if (!objectId) { throw new RangeError('instantiateTable requires an objectId to be given') } instance[OBJECT_ID] = objectId instance[CONFLICTS] = Object.freeze({}) instance.entries = entries || {} instance.opIds = opIds || {} return instance } module.exports = { Table, instantiateTable }