UNPKG

modelld

Version:

A JavaScript API for selecting and manipulating linked data subgraphs

487 lines (432 loc) 18.5 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.Model = undefined; var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; exports.modelFactory = modelFactory; var _immutable = require('immutable'); var _immutable2 = _interopRequireDefault(_immutable); var _util = require('./util'); var _field = require('./field'); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } /** * A Model represents an RDF subgraph. Specifically, it represents a number of * RDF quads all relating to the same subject. It allows for convenient * querying and updating of RDF data in a functional and immutable manner. * * @typedef {Object} Model * @property {Object} subject - The subject of the model represented as an RDF * NamedNode. * @property {Immutable.Map<String, Field[]>} _fields - The fields of this model * keyed by the field keys, which are user-specified aliases for RDF predicates. * @property {Array[Object]} graveyard - An array of RDF quads which have been * removed from the model. */ /** * Generates a factory for creating models. * * @param {Object} rdf - An RDF library, currently assumed to be rdflib.js. * @param {Object} fieldMap - A mapping of predicate aliases to RDF predicate * nodes. For example: { 'name': '<http://xmlns.com/foaf/0.1/name>' } * @param {Object} fieldCreators - A mapping from field keys to field factory * functions. * @returns {Function} - A factory function for creating actual models. The * factory takes three arguments - an RDF graph object as the data source, the * URI of the named graph which new fields are added to, and the subject of the * model as a string. */ function modelFactory(rdf, fieldMap) { return function (graph, defaultNamedGraph, subjectStr) { var fieldCreators = {}; var subject = rdf.NamedNode.fromValue(subjectStr); var fields = _immutable2.default.Map(Object.keys(fieldMap).reduce(function (prevFields, fieldName) { var fieldPredicate = fieldMap[fieldName]; var matchingQuads = graph.statementsMatching(subject, fieldPredicate); var fieldCreator = (0, _field.fieldFactory)(fieldPredicate); fieldCreators[fieldName] = fieldCreator; var matchingFields = matchingQuads.map(function (quad) { return fieldCreator.fromQuad(quad); }); return _extends({}, prevFields, _defineProperty({}, fieldName, matchingFields)); }, {})); // By definition, all the predicates in `fieldMap` must be unique, hence // inverting the map to have a (predicate -> fieldKey) mapping is safe. var reverseFieldMap = Object.keys(fieldMap).reduce(function (rdxn, fieldKey) { return _extends({}, rdxn, _defineProperty({}, fieldMap[fieldKey], fieldKey)); }, {}); return new Model(subject, fields, defaultNamedGraph, [], fieldCreators, reverseFieldMap); }; } var Model = exports.Model = function () { /** * Creates a model. Requires subject, fields, and optional graveyard. * * @constructor * @param {Object} subject - The subject of this model as an RDF subject * object. * @param {Immutable.Map.<String, Field>} fields - a map of field keys to * field objects. Field keys are aliases for a particular RDF predicate. * @param {Field[]=} graveyard - An optional array of fields which have been * removed from the model. * @returns {Model} the newly constructed model. */ function Model(subject, fields, defaultNamedGraph) { var graveyard = arguments.length <= 3 || arguments[3] === undefined ? [] : arguments[3]; var fieldCreators = arguments.length <= 4 || arguments[4] === undefined ? {} : arguments[4]; var reverseFieldMap = arguments.length <= 5 || arguments[5] === undefined ? {} : arguments[5]; _classCallCheck(this, Model); this.subject = subject; this._fields = fields; this.defaultNamedGraph = defaultNamedGraph; this.fieldCreators = fieldCreators; this.reverseFieldMap = reverseFieldMap; this.graveyard = graveyard; Object.freeze(this); } _createClass(Model, [{ key: 'fromCurrentState', value: function fromCurrentState(_ref) { var _ref$fields = _ref.fields; var fields = _ref$fields === undefined ? this._fields : _ref$fields; var _ref$defaultNamedGrap = _ref.defaultNamedGraph; var defaultNamedGraph = _ref$defaultNamedGrap === undefined ? this.defaultNamedGraph : _ref$defaultNamedGrap; var _ref$graveyard = _ref.graveyard; var graveyard = _ref$graveyard === undefined ? this.graveyard : _ref$graveyard; var _ref$fieldCreators = _ref.fieldCreators; var fieldCreators = _ref$fieldCreators === undefined ? this.fieldCreators : _ref$fieldCreators; var _ref$reverseFieldMap = _ref.reverseFieldMap; var reverseFieldMap = _ref$reverseFieldMap === undefined ? this.reverseFieldMap : _ref$reverseFieldMap; return new Model(this.subject, fields, defaultNamedGraph, graveyard, fieldCreators, reverseFieldMap); } /** * Get all the fields for a given key. * * @param {String} key - the key of the fields to look up. * @returns {Field[]} An array of fields for the given key. */ }, { key: 'fields', value: function fields(key) { return this._fields.get(key) || []; } /** * Get all the field values for a given key. * * @param {String} key - the key of the fields to look up. * @returns {String[]} An array of field values for the given key. */ }, { key: 'get', value: function get(key) { return this.fields(key).map(function (field) { return field.value; }); } /** * Get one of the field values for a given key. This just looks for the first * field value, but order isn't guaranteed. * * @param {String} key - the key of the field to look up. * @returns {String|undefined} The field value for the given key, or undefined * if none was found. */ }, { key: 'any', value: function any(key) { return this.fields(key).map(function (field) { return field.value; })[0]; } /** * Creates a model with an extra field. * * @param {String} key - the key of the fields to add to. * @param fieldValue - the value of the field to add. * @returns {Model} - the updated model. */ }, { key: 'add', value: function add(key, fieldValue) { var options = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2]; var namedGraph = options.namedGraph || this.defaultNamedGraph; return this.fromCurrentState({ fields: this._fields.set(key, [].concat(_toConsumableArray(this._fields.get(key)), [this.fieldCreators[key](fieldValue, namedGraph, options)])) }); } /** * Adds a field from an RDF quad. * * @param {Object} quad - the RDF quad to be converted to a field and added to * this model. * @returns {Model} - the updated model. */ }, { key: 'addQuad', value: function addQuad(quad) { var key = this.reverseFieldMap[quad.predicate]; return this.fromCurrentState({ fields: this._fields.set(key, [].concat(_toConsumableArray(this._fields.get(key)), [this.fieldCreators[key].fromQuad(quad)])) }); } /** * Creates a model with a removed field. * * @param {Field} field - the field to remove. * @returns {Model} - the updated model. */ }, { key: 'remove', value: function remove(field) { if (!this.find(function (f) { return f.id === field.id; })) { return this; } return this.filterToGraveyard(function (f) { return f.id !== field.id; }); } /** * Creates a model with a modified field. * * @param {Field} oldField - the field which should be removed. * @param newFieldValue - the new field's value. * @param {Object} newFieldOptions - arguments to create the new field. * @param {String|NamedNode} newFieldOptions.namedGraph - the namedgraph in * which to store this field, if different from the model's default named * graph. * @param {Boolean} newFieldOptions.namedNode - whether the new field is a * named node or not. * @returns {Model} - the updated model. */ }, { key: 'set', value: function set(oldField, newFieldValue, newFieldOptions) { return this.map(function (field) { return field.id === oldField.id ? field.set(_extends({}, newFieldOptions, { value: newFieldValue })) : field; }); } /** * Creates a model with a modified field chosen by key. This method should * only be called with keys for which only one field exists, as there are no * guarantees for how it picks a field. A new field for the specified key if * no existing field is found. * * @param {String} key - the key of a field to replace. * @param fieldValue - the new field value. * @param {Object} fieldOptions - arguments to create the new field. * @param {String|NamedNode} fieldOptions.namedGraph - the namedgraph in which * to store this field, if different from the model's default named graph. * @param {Boolean} fieldOptions.namedNode - whether the new field is a named * node or not. * @returns {Model} - the updated model. */ }, { key: 'setAny', value: function setAny(key, fieldValue, fieldOptions) { var firstField = this.fields(key)[0]; return firstField ? this.set(firstField, fieldValue, fieldOptions) : this.add(key, fieldValue, fieldOptions); } /** * Compare the current state of the model with its original state and * determine, for each RDF named graph in the model, which fields should be * removed and which should be inserted. * * @param {Object} rdf - An RDF library, currently assumed to be rdflib.js. * @param {Model} model - the model. * @returns {Object} A mapping from graph URIs to the RDF quads (as strings) * which should be inserted and deleted within those URIs. For example: * { * 'http://example.com/one-resource': { * toIns: [ Field1 ], * toDel: [ ], * }, * 'http://example.com/another-resource': { * toIns: [ ], * toDel: [ Field2 ], * }, * } */ }, { key: 'diff', value: function diff(rdf) { var _this = this; var diffMap = this._fields.toArray().reduce(function (reduction, cur) { return [].concat(_toConsumableArray(reduction), _toConsumableArray(cur)); }).reduce(function (previousMap, field) { var map = _extends({}, previousMap); var newQuad = field.toQuad(rdf, _this.subject); var newSourceURI = newQuad.graph.value; var originalQuad = field.originalQuad(rdf, _this.subject); var originalSourceURI = originalQuad ? originalQuad.graph.value : null; var fieldHasChanged = !originalQuad || !newQuad.equals(originalQuad); if (fieldHasChanged) { if (originalQuad) { if (!(0, _util.isDefined)(map[originalSourceURI])) { map[originalSourceURI] = { toDel: [], toIns: [] }; } map[originalSourceURI].toDel.push(originalQuad.toString()); } if (!(0, _util.isDefined)(map[newSourceURI])) { map[newSourceURI] = { toDel: [], toIns: [] }; } map[newSourceURI].toIns.push(newQuad.toString()); } return map; }, {}); this.graveyard.forEach(function (field) { var quad = field.originalQuad(rdf, _this.subject); if (quad) { var uri = quad.graph.uri; if (!(0, _util.isDefined)(diffMap[uri])) { diffMap[uri] = { toDel: [], toIns: [] }; } diffMap[uri].toDel.push(quad.toString()); } }, diffMap); return diffMap; } /** * Save model updates using an LDP web client. * * @param {Object} rdf - An RDF library, currently assumed to be rdflib.js. * @param {Object} web - A web client library, currently assumed to be * solid-web-client. * @param {Model} model - The model to save. * @returns {Promise<Model>} The updated model. */ }, { key: 'save', value: function save(rdf, web) { var _this2 = this; var diffMap = this.diff(rdf); var urisToPatch = Object.keys(diffMap); if (urisToPatch.length === 0) { return Promise.resolve(this); } return patchURIs(rdf, web, diffMap).then(function (patchedURIs) { var updatedModel = _this2.map(function (field) { return patchedURIs.has(field.namedGraph.value) ? field.fromCurrentState(rdf, _this2.subject) : field; }).clearGraveyard(); var allPatchesSucceded = patchedURIs.size === urisToPatch.length; if (allPatchesSucceded) { return updatedModel; } else { var err = new Error('Not all patches succeeded'); err.model = updatedModel; err.diffMap = diffMap; err.failedURIs = new Set(urisToPatch.filter(function (uri) { return !patchedURIs.has(uri); })); throw err; } }); } /** * Maps a function onto every field, and returns a new model with the * corresponding fields. * * @param {Function(Field)} fn - A function to apply to every field in the * model. The function receives one argument - the current field. * @param {Model} model - the model being mapped over. * @returns {Model} A new model containing the result of the field mapping. */ }, { key: 'map', value: function map(fn) { return this.fromCurrentState({ fields: this._fields.map(function (fieldsArray) { return fieldsArray.map(fn); }) }); } /** * Locate and return the first field on a model that satisfies a predicate * function. * * @param {Function(Field)} - the predicate function which takes each field on * the model as an argument. * @returns {Field} - The identified field. */ }, { key: 'find', value: function find(fn) { return this._fields.reduce(function (fields, curFieldsArray) { return [].concat(_toConsumableArray(fields), _toConsumableArray(curFieldsArray)); }).find(function (field) { return fn(field); }); } /** * Filter fields and mov them to the graveyard if they don't pass a predicate * function. * * @param {Function(Field)} fn - A predicate function returning a Boolean to * apply to every field in the model. Fields which pass the predicate stay in * the model, and those which fail the test are removed. * @returns {Model} A new model with some fields filtered into the graveyard. */ }, { key: 'filterToGraveyard', value: function filterToGraveyard(fn) { var removedFields = []; var newFields = this._fields.map(function (fieldsArray) { return fieldsArray.filter(function (field) { var testPassed = fn(field); if (!testPassed) { removedFields.push(field); } return testPassed; }); }); return this.fromCurrentState({ fields: newFields, graveyard: [].concat(_toConsumableArray(this.graveyard), removedFields) }); } /** * Returns a new model with the same fields but an empty graveyard. * * @returns {Model} - The new graveyard-less model */ }, { key: 'clearGraveyard', value: function clearGraveyard() { return this.fromCurrentState({ graveyard: [] }); } }]); return Model; }(); /** * Given a diff map (from Model.diff), patch each resource in the diff map using * the web client's patch method. Return a Promise which resolves to the set of * URIs which were successfully patched. Note that the Promise does not reject; * if some resources failed to be patched, they're not included in the URI set. * * @param {Object} rdf - An RDF library, currently assumed to be rdflib.js. * @param {Object} web - A web client library, currently assumed to be * @param {Object} diffMap - The result of running Model.diff() on a model. * @returns {Promise<Set<String>>} A Promise which always resolves to the set of * URIs for which the patches succeeded. */ function patchURIs(rdf, web, diffMap) { var urisToPatch = Object.keys(diffMap); return Promise.all(urisToPatch.map(function (uri) { return web.patch(uri, diffMap[uri].toDel, diffMap[uri].toIns).then(function (solidResponse) { return { URI: solidResponse.url, succeeded: true }; }).catch(function (solidResponse) { return { URI: solidResponse.url, succeeded: false }; }); })).then(function (sourceResults) { return new Set(sourceResults.filter(function (result) { return result.succeeded; }).map(function (result) { return result.URI; })); }); }