diff --git a/.eslintrc.json b/.eslintrc.json index fedf64c..a49764c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,7 +19,9 @@ "arrow-parens": "off", "no-underscore-dangle": "off", "import/extensions": "off", - "no-warning-comments": "warn" + "no-warning-comments": "warn", + "no-use-before-define": ["error", { "functions": false }], + "no-console": ["warn", { "allow": ["warn"] }] }, "ignorePatterns": ["dist/**"], "overrides": [ diff --git a/docs/api.md b/docs/api.md index 9636f02..9836f1c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -235,12 +235,12 @@ Create a farm model for generating and manipulating farmOS data structures. * [model](#model) ⇒ [FarmModel](#FarmModel) - * [~getSchemata([entity], [type])](#model..getSchemata) ⇒ [EntitySchemata](#EntitySchemata) \| [BundleSchemata](#BundleSchemata) \| [JsonSchemaDereferenced](#JsonSchemaDereferenced) + * [~getSchemata([entity], [bundle])](#model..getSchemata) ⇒ [EntitySchemata](#EntitySchemata) \| [BundleSchemata](#BundleSchemata) \| [JsonSchemaDereferenced](#JsonSchemaDereferenced) * [~setSchemata(...args)](#model..setSchemata) -### model~getSchemata([entity], [type]) ⇒ [EntitySchemata](#EntitySchemata) \| [BundleSchemata](#BundleSchemata) \| [JsonSchemaDereferenced](#JsonSchemaDereferenced) +### model~getSchemata([entity], [bundle]) ⇒ [EntitySchemata](#EntitySchemata) \| [BundleSchemata](#BundleSchemata) \| [JsonSchemaDereferenced](#JsonSchemaDereferenced) Retrieve all schemata that have been previously set, or the schemata of a particular entity, or one bundle's schema, if specified. @@ -249,13 +249,13 @@ particular entity, or one bundle's schema, if specified. | Param | Type | Description | | --- | --- | --- | | [entity] | String | The name of a farmOS entity (eg, 'asset', 'log', etc). | -| [type] | String | The entity's type (aka, bundle). | +| [bundle] | String | The entity's bundle (eg, 'activity' for type 'log--activity'). | ### model~setSchemata(...args) Load all schemata, the schemata of a particular entity, or one bundle's -schema, if spcified. +schema, if specified. **Kind**: inner method of [model](#model) **Void**: @@ -365,7 +365,7 @@ Create a farm client for interacting with farmOS servers. | Name | Type | Description | | --- | --- | --- | | id | String | A v4 UUID as specified by RFC 4122. | -| type | String | Corresponding to the entity bundle (eg, 'activity'). | +| type | String | The combined form of entity & bundle (eg, 'log--activity'). | | attributes | Object | Values directly attributable to this entity. | | relationships | Object.<String, (EntityReference\|Array.<EntityReference>)> | References to other entities that define a one-to-one or one-to-many relationship. | | meta | Object | Non-domain information associated with the creation, modification, storage and transmission of the entity. | diff --git a/docs/entities.md b/docs/entities.md index 1d71ed3..f36c827 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -74,7 +74,7 @@ So for example, to request completed activity logs, you could use the following ```js const filter = { - type: { $eq: 'activity' }, + type: { $eq: 'log--activity' }, status: { $eq: 'done' }, }; const request = farm.log.fetch({ filter }); @@ -86,9 +86,9 @@ Generally speaking, it's a good idea to include the `type` in filter queries whe const filter = { type: { $or: [ - { $eq: 'activity' }, - { $eq: 'harvest' }, - { $eq: 'input' }, + { $eq: 'log--activity' }, + { $eq: 'log--harvest' }, + { $eq: 'log--input' }, ], }, status: { $eq: 'done' }, @@ -100,9 +100,9 @@ This can be abbreviated somewhat by omitting the `$or` operator, and simply assi ```js const filter = { type: [ - { $eq: 'activity' }, - { $eq: 'harvest' }, - { $eq: 'input' }, + { $eq: 'log--activity' }, + { $eq: 'log--harvest' }, + { $eq: 'log--input' }, ], status: { $eq: 'done' }, }; @@ -112,7 +112,7 @@ And of course, a even more common shorthand is to omit the `$eq` operators, mere ```js const filter = { - type: ['activity', 'harvest', 'input'], + type: ['log--activity', 'log--harvest', 'log--input'], status: 'done', }; ``` @@ -122,7 +122,7 @@ As you can see, this works even for the elements of the array, which have been r ```js const filter = { $and: [ - { type: ['activity', 'harvest', 'input'] }, + { type: ['log--activity', 'log--harvest', 'log--input'] }, { status: 'done' }, ], }; @@ -167,7 +167,7 @@ It's also important to note that this limit will apply separately for each entit ```js const filter = { $and: [ - { type: ['activity', 'harvest', 'input'] }, + { type: ['log--activity', 'log--harvest', 'log--input'] }, { status: 'done' }, ], }; diff --git a/docs/index.md b/docs/index.md index 7b5e08c..ef08120 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,11 +56,11 @@ farm.schema.fetch() ### 5. CRUD operations with a farmOS log ```js -const initProps = { type: 'activity', name: 'did some stuff' }; +const initProps = { type: 'log--activity', name: 'did some stuff' }; const activity = farm.log.create(initProps); const { id } = activity; // `id` is a v4 UUID farm.log.send(activity) - .then(() => farm.log.fetch({ filter: { type: 'activity', id } })) + .then(() => farm.log.fetch({ filter: { type: 'log--activity', id } })) .then(({ data: [remoteActivity] }) => { const updateProps = { name: 'did some more stuff' }; const updatedActivity = farm.log.update(activity, updateProps); diff --git a/docs/remotes.md b/docs/remotes.md index 5a67061..adc3c2e 100644 --- a/docs/remotes.md +++ b/docs/remotes.md @@ -62,3 +62,8 @@ farm.remote.info().then((res) => { /** etc */ }); ``` For farmOS based on Drupal 9 and JSON:API, this is essentially a shorthand for requesting the `/api` endpoint. + +## Subrequests ⚠️ __WARNING: EXPERIMENTAL!__ ⚠️ +To reduce the number of roundtrip requests, the [Drupal `subrequests` module](https://www.drupal.org/project/subrequests) is included in most farmOS instances. A special syntax is employed in farmOS.js, however, which allows for more concise and intuitive descriptions of the subrequest dependency graph. + +Because this is still experimental, it should not be used in production, as there are known issues with its implementation. Until it is considered stable, refer to the [`test/subrequest.js`](https://github.com/farmOS/farmOS.js/blob/main/test/subrequest.js) for the most up-to-date examples on its usage. diff --git a/docs/schemata.md b/docs/schemata.md index ac336ca..fe7e250 100644 --- a/docs/schemata.md +++ b/docs/schemata.md @@ -1,7 +1,7 @@ # Schemata ## Schema publication enables interoperability -In order to generate and modify farmOS data structures, your farm instance must have the corresponding schema for each entity type (aka, the "bundle") that you wish to work with. For instance, if you wish to create an activity log and send it to a server, you will need the schema for logs with the type `'activity'`. +In order to generate and modify farmOS data structures, your farm instance must have the corresponding schema for each entity type (aka, the "bundle") that you wish to work with. For instance, if you wish to create an activity log and send it to a server, you will need the schema for logs with the type `'log--activity'`. These schemata are formatted using the [JSON Schema](https://json-schema.org) specification. @@ -17,7 +17,7 @@ farm.schema.set('log', 'activity', activityLogJSONSchema); Once you have done so, you can create an activity log, which will be given a UUID and valid defaults: ```js -const activity = farm.log.create({ type: 'activity' }); +const activity = farm.log.create({ type: 'log--activity' }); console.log(activity.timestamp) // => '2021-11-16T21:54:54.888Z' ``` @@ -50,22 +50,22 @@ One caveat to be aware of, however, is that calls to `farm.schema.set` will over ```js const activityLogJSONSchema = { /** JSON Schema */ }; farm.schema.set({ log: { activity: activityLogJSONSchema } }); -const harvest = farm.log.create({ type: 'harvest' }); // THROWS! -const equipment = farm.asset.create({ type: 'equipment' }); // THROWS! +const harvest = farm.log.create({ type: 'log--harvest' }); // THROWS! +const equipment = farm.asset.create({ type: 'asset--equipment' }); // THROWS! ``` Those calls to create a new harvest log and equipment asset will throw, because the schemata for them are no longer set. To avoid that scenario, it would have been better to call: ```js farm.schema.set('log', 'activity', activityLogJSONSchema); -const harvest = farm.log.create({ type: 'harvest' }); // works -const equipment = farm.asset.create({ type: 'equipment' }); // works +const harvest = farm.log.create({ type: 'log--harvest' }); // works +const equipment = farm.asset.create({ type: 'log--equipment' }); // works ``` In this last example, only the activity log schema would have been overwritten, preserving the original schemata for harvest logs and equipment assets. ### Instantiate your farm with the `schemata` option -If schemata are available to your application when you instantiate `farmOS`, you can also provide a `schemata` option to the constructor. This option should have as its value an object, containing each entity as a key (eg, `'log'`), whose value is an object containing each entity type as a key (eg, `'activity'`), whose value is the corresponding schema for that entity type. +If schemata are available to your application when you instantiate `farmOS`, you can also provide a `schemata` option to the constructor. This option should have as its value an object, containing each entity as a key (eg, `'log'`), whose value is an object containing each entity type as a key (eg, `'log--activity'`), whose value is the corresponding schema for that entity type. ```js const myFarm = farmOS({ diff --git a/src/client/adapter/index.js b/src/client/adapter/index.js index 9526872..bace690 100644 --- a/src/client/adapter/index.js +++ b/src/client/adapter/index.js @@ -3,12 +3,12 @@ import chain from 'ramda/src/chain.js'; import compose from 'ramda/src/compose.js'; import concat from 'ramda/src/concat.js'; import evolve from 'ramda/src/evolve.js'; -import mapObjIndexed from 'ramda/src/mapObjIndexed.js'; import map from 'ramda/src/map.js'; import path from 'ramda/src/path.js'; import reduce from 'ramda/src/reduce.js'; import client from '../index.js'; import defaultEntities, { entityMethods } from '../../entities.js'; +import { parseEntityType, splitFilterByType } from '../../types.js'; import { generateFieldTransforms, transformD9Schema, transformLocalEntity, transformFetchResponse, transformSendResponse, @@ -22,44 +22,6 @@ import { const DRUPAL_PAGE_LIMIT = 50; -export function parseBundles(filter, validTypes) { - const bundles = []; - // The filter must either be an object (logical $and) or an array (logical $or). - if (Array.isArray(filter) || Array.isArray(filter.$or)) { - (Array.isArray(filter) ? filter : filter.$or).forEach((f) => { - parseBundles(f).forEach(({ name, filter: bundleFilter }) => { - const i = bundles.findIndex(b => b.name === name); - if (i > -1) { - // Concat on an empty array to flatten either bundle or both. - bundles[i].filter = [].concat(bundles[i].filter, bundleFilter); - } else { - bundles.push({ name, filter: bundleFilter }); - } - }); - }); - return bundles; - } - if (typeof filter !== 'object') return bundles; - const { type, ...rest } = typeof filter.$and === 'object' ? filter.$and : filter; - if (typeof type === 'string') { - if (!validTypes.includes(type)) return bundles; - bundles.push({ name: type, filter: rest }); - } - if (Array.isArray(type)) { - type.forEach((t) => { - if (validTypes.includes(t)) { - bundles.push({ name: t, filter: rest }); - } - }); - } - if ([undefined, null].includes(type)) { - validTypes.forEach((t) => { - bundles.push({ name: t, filter: rest }); - }); - } - return bundles; -} - const aggregateBundles = reduce((aggregate, result) => { const { reason, value, status } = result; if (status === 'fulfilled') { @@ -112,35 +74,30 @@ export default function adapter(model, opts) { return { ...connection, schema: { - fetch(entName, type) { - return connection.schema.fetch(entName, type) + fetch(entName, bundle) { + return connection.schema.fetch(entName, bundle) .then((schemata) => { if (!entName) { - return mapObjIndexed( - (entitySchemata, entityName) => map( - transformD9Schema(entityName), - entitySchemata, - ), - schemata, - ); + return map((s) => map(transformD9Schema, s), schemata); } - if (!type) { - return map(transformD9Schema(entName), schemata); + if (!bundle) { + return map(transformD9Schema, schemata); } - return transformD9Schema(entName)(schemata); + return transformD9Schema(schemata); }); }, }, ...entityMethods(({ nomenclature: { name, shortName } }) => ({ ...connection[shortName], - fetch: ({ filter, limit }) => { - const validTypes = Object.keys(model.schema.get(name)); - const bundles = parseBundles(filter, validTypes); - const bundleRequests = bundles.map(({ name: bundle, filter: bundleFilter }) => { - const fetchOptions = { - filter: bundleFilter, - limit, - }; + /** @type {(options: import('../fetch.js').FetchOptions) => Promise} */ + fetch: (options) => { + const { filter, limit, sort } = options; + const validTypes = Object.keys(model.schema.get(name)).map(b => `${name}--${b}`); + const bundleRequests = splitFilterByType(filter, validTypes).map((f) => { + const { type, ...tFilter } = f; + /** @type {import('../fetch.js').FetchOptions} */ + const fetchOptions = { ...tFilter, limit, sort }; + const { bundle } = parseEntityType(type); if (name in fieldTransforms && bundle in fieldTransforms[name]) { fetchOptions.filterTransforms = fieldTransforms[name][bundle]; } @@ -155,8 +112,8 @@ export default function adapter(model, opts) { .then(handleBundleResponse); }, send: data => connection[shortName].send( - data.type, - transformLocalEntity(name, data, fieldTransforms), + parseEntityType(data.type).bundle, + transformLocalEntity(data, fieldTransforms), ).then(transformSendResponse(name)), }), entities), }; diff --git a/src/client/adapter/transformations.js b/src/client/adapter/transformations.js index 13efa67..900119c 100644 --- a/src/client/adapter/transformations.js +++ b/src/client/adapter/transformations.js @@ -9,8 +9,8 @@ import pick from 'ramda/src/pick.js'; import prop from 'ramda/src/prop.js'; import path from 'ramda/src/path.js'; import replace from 'ramda/src/replace.js'; -import typeToBundle from '../type-to-bundle.js'; import { getPath } from '../../json-schema/index.js'; +import { parseEntityType } from '../../types.js'; import { isObject } from '../../utils.js'; const dropMilliseconds = replace(/\.\d\d\d/, ''); @@ -118,18 +118,18 @@ const transformResourceSchema = (subschema) => { return { type, title, items: resourceSchema }; }; -export const transformD9Schema = entName => (d9Schema) => { +export const transformD9Schema = (d9Schema) => { const { $id, $schema, title, definitions: { type, attributes, relationships }, } = d9Schema; - const bundle = typeToBundle(entName, type.const); - const defaultTitle = `${bundle} ${entName}`; + const { entity, bundle } = parseEntityType(type.const); + const defaultTitle = `${bundle} ${entity}`; const transformAttributeFields = evolve({ properties: mapObjIndexed((subschema, propName) => { const hasOwnDefault = isObject(subschema) && 'default' in subschema; if (hasOwnDefault) return subschema; - const standardDefault = path([entName, propName], attributeDefaults); + const standardDefault = path([entity, propName], attributeDefaults); if (!standardDefault) return subschema; return { ...subschema, default: standardDefault }; }), @@ -143,7 +143,7 @@ export const transformD9Schema = entName => (d9Schema) => { ); const transformRelationshipsSchema = compose( transformRelationshipFields, - removeDrupalMetaSchemas(drupalMetaFields.relationships.concat(`${entName}_type`)), + removeDrupalMetaSchemas(drupalMetaFields.relationships.concat(`${entity}_type`)), ); return { @@ -153,7 +153,7 @@ export const transformD9Schema = entName => (d9Schema) => { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, - type: { const: bundle }, + type, meta: { type: 'object' }, attributes: transformAttributesSchema(attributes), relationships: transformRelationshipsSchema(relationships), @@ -167,27 +167,28 @@ export const transformD9Schema = entName => (d9Schema) => { * @typedef {Object} FieldTransforms */ /** - * @param {String} ent Entity name - * @param {String} type Bundle name + * @param {String} type Entity type (eg, 'log--activity') * @param {FieldTransforms} fns Collection of tranform functions * @returns {Object} */ -const safeTransforms = (ent, type, fns = { [ent]: { [type]: {} } }) => fns[ent][type]; +const safeTransforms = (type, fns = {}) => { + const { entity, bundle } = parseEntityType(type); + const transforms = fns[entity] && fns[entity][bundle]; + return transforms || {}; +}; /** * For transforming local entities into acceptable format for D9 JSON:API farmOS. - * @param {String} entName Entity name * @param {import('../../entities').Entity} data The actual data of such an entity * @param {FieldTransforms} transforms Collection of transforms * @returns {Object} */ -export const transformLocalEntity = (entName, data, transforms) => compose( +export const transformLocalEntity = (data, transforms) => compose( dissoc('meta'), evolve({ - type: t => `${entName}--${t}`, attributes: { timestamp: dropMilliseconds, - ...safeTransforms(entName, data.type, transforms), + ...safeTransforms(data.type, transforms), }, relationships: map(r => ({ data: r })), }), @@ -211,7 +212,7 @@ const transformRemoteEntity = (entName, setLastSync = false) => ({ id, type, attributes = emptyAttrs, relationships = {}, }) => ({ id, - type: typeToBundle(entName, type), + type, meta: { created: safeIso(attributes.created), changed: safeIso(attributes.changed), diff --git a/src/client/fetch.js b/src/client/fetch.js index 2dd9044..49cbc55 100644 --- a/src/client/fetch.js +++ b/src/client/fetch.js @@ -1,21 +1,53 @@ +import compose from 'ramda/src/compose'; +import concat from 'ramda/src/concat'; +import defaultTo from 'ramda/src/defaultTo'; +import isEmpty from 'ramda/src/isEmpty'; +import reduce from 'ramda/src/reduce'; +import unless from 'ramda/src/unless'; import parseFilter from './parse-filter.js'; +/** + * @typedef {Object} FetchOptions + * @property {Object} [filter] + * @property {Object} [filterTransforms] + * @property {Number} [limit] + * @property {Object} [sort] + */ + /** * farmOS client method for fetching entities from a Drupal JSON:API server * @typedef {Function} fetchEntity * @param {String} bundle The bundle type (eg, 'activity', 'equipment', etc). - * @param {Object} [options] Options for the fetch request. - * @property {Object} [options.filter] - * @property {Object} [options.filterTransforms] - * @property {Object} [options.limit] + * @param {FetchOptions} [options] Options for the fetch request. * @returns {Promise} */ /** @type {(limit: Number?) => String} */ const parseLimit = limit => (Number.isInteger(limit) && limit > 0 ? `&page[limit]=${limit}` : ''); -const parseFetchParams = ({ filter, filterTransforms, limit }) => - parseFilter(filter, { filterTransforms }) + parseLimit(limit); +const concatSortParams = (prev, [path, direction]) => + `${prev !== '' ? ',' : ''}${direction === 'DESC' ? '-' : ''}${path}`; +/** @type {(sort: Object?) => String} */ +const parseSort = compose( + unless(isEmpty, concat('&sort=')), + reduce(concatSortParams, ''), + Object.entries, + defaultTo({}), +); + +/** + * @param {FetchOptions} options + * @returns {String} + */ +export function parseFetchParams(options = {}) { + const { + filter, filterTransforms, limit, sort, + } = options; + const filterParams = parseFilter(filter, { filterTransforms }); + const limitParams = parseLimit(limit); + const sortParams = parseSort(sort); + return filterParams + limitParams + sortParams; +} /** * @param {String} entity diff --git a/src/client/parse-filter.js b/src/client/parse-filter.js index 0131636..52bae16 100644 --- a/src/client/parse-filter.js +++ b/src/client/parse-filter.js @@ -25,6 +25,21 @@ const comparison = { $nin: 'NOT%20IN', }; +const truthyForms = [true, 1, 'true', 'TRUE', 'T']; +const falseyForms = [false, 0, 'false', 'FALSE', 'F']; +const booleanForms = [...truthyForms, ...falseyForms]; +const booleanTransform = bool => (truthyForms.includes(bool) ? 1 : 0); +function transformRawValue(transform, raw) { + let value = raw; + if (typeof transform === 'function') { + value = transform(value); + } + if (booleanForms.includes(value)) { + value = booleanTransform(value); + } + return value; +} + export default function parseFilter(filter = {}, options = {}) { const { filterTransforms = {} } = options; @@ -33,9 +48,7 @@ export default function parseFilter(filter = {}, options = {}) { const pre = `filter[${path}-${index}-filter][condition]`; const membership = comGroup ? `&${pre}[memberOf]=${comGroup}` : ''; const [[op, rawValue], ...tail] = Object.entries(expr); - const val = typeof filterTransforms[path] === 'function' - ? filterTransforms[path](rawValue) - : rawValue; + const val = transformRawValue(filterTransforms[path], rawValue); if (val === null) { const pathStr = `${amp}filter[${path}-filter][condition][path]=${path}`; const opStr = `&filter[${path}-filter][condition][operator]=IS%20NULL`; diff --git a/src/client/schema.js b/src/client/schema.js index f12e348..fc9c985 100644 --- a/src/client/schema.js +++ b/src/client/schema.js @@ -1,6 +1,8 @@ +import compose from 'ramda/src/compose.js'; +import filter from 'ramda/src/filter.js'; import map from 'ramda/src/map.js'; -import prop from 'ramda/src/prop.js'; -import typeToBundle from './type-to-bundle.js'; +import path from 'ramda/src/path.js'; +import { parseEntityType } from '../types.js'; /** * Fetch JSON Schema documents for farmOS data structures. @@ -19,33 +21,38 @@ import typeToBundle from './type-to-bundle.js'; * @param {Object} entities * @returns {FetchSchema} */ -const fetchSchema = (request, entities) => (entity, bundle) => { - if (!entity) { - const schemata = map(() => ({}), entities); - return request('/api/') - .then(res => Promise.all(Object.keys(res.data.links) - .filter(type => Object.keys(entities) - .some(name => type.startsWith(`${name}--`))) - .map((type) => { - const [entName, b] = type.split('--'); - return request(`/api/${entName}/${b}/resource/schema`) - .then(({ data: schema }) => { schemata[entName][b] = schema; }); - }))) - .then(() => schemata); +const fetchSchema = (request, entities) => (...args) => { + const schemata = map(() => ({}), entities); + let entity; let bundle; + const [arg1, arg2] = args; + if (arg1 && !arg2) { + ({ entity = arg1, bundle } = parseEntityType(arg1)); } - if (!bundle) { - return request('/api/') - .then(res => Promise.all(Object.keys(res.data.links) - .filter(type => type.startsWith(`${entity}--`)) - .map((type) => { - const b = typeToBundle(entity, type); - return request(`/api/${entity}/${b}/resource/schema`) - .then(({ data: schema }) => [b, schema]); - })) - .then(Object.fromEntries)); + if (arg1 && arg2) { + ({ entity = arg1, bundle = arg2 } = parseEntityType(arg2)); } - return request(`/api/${entity}/${bundle}/resource/schema`) - .then(prop('data')); + const toSchemaRequest = ({ entity: e, bundle: b }) => + request(`/api/${e}/${b}/resource/schema`).then(({ data: schema }) => { + schemata[e][b] = schema; + return schema; + }); + if (entity in entities && bundle) { + return toSchemaRequest({ entity, bundle }); + } + const mapToSchemaRequests = compose( + map(toSchemaRequest), + !entity ? filter(o => o.entity in entities) : filter(o => o.entity === entity), + map(parseEntityType), + Object.keys, + path(['data', 'links']), + ); + return request('/api/') + .then(response => Promise.all(mapToSchemaRequests(response))) + .then(() => { + if (entity in entities && bundle) return schemata[entity][bundle] || null; + if (entity in entities) return schemata[entity]; + return schemata; + }); }; export default fetchSchema; diff --git a/src/client/subrequest.js b/src/client/subrequest.js new file mode 100644 index 0000000..ebce474 --- /dev/null +++ b/src/client/subrequest.js @@ -0,0 +1,494 @@ +import any from 'ramda/src/any'; +import assoc from 'ramda/src/assoc'; +import chain from 'ramda/src/chain'; +import compose from 'ramda/src/compose'; +import evolve from 'ramda/src/evolve'; +import rFilter from 'ramda/src/filter'; +import map from 'ramda/src/map'; +import mapObjIndexed from 'ramda/src/mapObjIndexed'; +import match from 'ramda/src/match'; +import mergeRight from 'ramda/src/mergeRight'; +import partition from 'ramda/src/partition'; +import path from 'ramda/src/path'; +import pick from 'ramda/src/pick'; +import pickBy from 'ramda/src/pickBy'; +import prop from 'ramda/src/prop'; +import reduce from 'ramda/src/reduce'; +import sort from 'ramda/src/sort'; +import startsWith from 'ramda/src/startsWith'; +import uniqBy from 'ramda/src/uniqBy'; +import entities from '../entities'; +import { parseEntityType, parseTypeFromFields, splitFilterByType } from '../types'; +import { generateFieldTransforms, transformLocalEntity } from './adapter/transformations'; +import { parseFetchParams } from './fetch'; + +// Constants for sending requests via Drupal's JSON:API module. +const BASE_URI = '/api'; +const SUB_URL = '/subrequests?_format=json'; +const headers = { + Accept: 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', +}; + +// Fields that contain a subrequest are separated as dependent fields, distinct +// from constant fields, whose values are known immediately. +const isKeyword = startsWith('$'); +const isSubrequest = o => any(isKeyword, Object.keys(o)); +const splitFields = partition(isSubrequest); + +// For use with fetch filters, to keep only their constant fields and drop any +// operators such as `'$lt'`, `'$or'`, etc. +const dropKeywords = pickBy((v, k) => !isKeyword(k)); + +// Subrequests that contain a JSONPath wildcard may have multiple subresponses, +// which are each given Content-Ids comprised of the original requestId appended +// w/ an additional fragment, which is itself comprised of the blueprint key +// where the wildcard was found plus an index. For instance, if a subrequest is +// given a uri of '/api/log/input/{{find-logs.body@$.data[*].id}}' and has a +// requestId of 'update-input', the first two subresponse that corresponds to it +// will have Content-Ids of 'update-input#uri{0}' and 'update-input#uri{1}', +// respectively. Therefore, each Content-Id is parsed so the subresponse can be +// more easily matched with its corresponding subrequest. +const contentIdRE = /([^#\s]+)(#(body|uri)\{(\d)\})?$/; +function parseContentId(string) { + const [ + contentId, requestId, fragment, key, index, + ] = match(contentIdRE, string); + return { + contentId, requestId, fragment, key, index, + }; +} + +function parseCommand(reqId) { + const pathSegments = reqId.split('::'); + const lastPathSegment = pathSegments[pathSegments.length - 1]; + const [command] = lastPathSegment.split(':'); + return command; +} +const commandRelations = { + $find: 'array', + $create: 'object', + $createIfNotFound: 'object', +}; +function parseDataToken(requestId) { + const fieldCommand = parseCommand(requestId); + const wildcard = commandRelations[fieldCommand] === 'array' ? '[*]' : ''; + return `${requestId}.body@$.data${wildcard}`; +} + +// Determine if a relationship for a given schema is one-to-one (ie, 'object') +// or one-to-many (ie, 'array'). +const typeOfRelationship = (schema, field) => path([ + 'properties', 'relationships', 'properties', field, 'type', +], schema); + +// Wrapper merely provides an instance of FarmObject via dependency injection. +export default function useSubrequests(farm) { + // Called before any requests have been sent, unlike resolveDependencies. This + // is mutually recursive with parseSubrequests, and is how each subrequest and + // its child subrequests are assigned their priority number, which accordingly + // determines the batch of subrequests in which it will be sent to the server. + function parseDependentFields(fields, command, prefix) { + const [dependentFields, constants] = splitFields(fields); + const { bundle, entity, type } = parseTypeFromFields(constants); + const requestId = `${prefix}::${command}:${type}`; + const dependencies = {}; let priority = 0; const subrequests = {}; + Object.entries(dependentFields).forEach(([field, sub]) => { + const nextPrefix = `${requestId}.${field}`; + dependencies[field] = []; + const requests = parseSubrequest(sub, {}, nextPrefix); + Object.entries(requests).forEach(([reqId, req]) => { + dependencies[field].push(reqId); + if (req.priority > priority) priority = req.priority; + subrequests[reqId] = req; + }); + }); + return { + bundle, constants, dependencies, entity, priority, requestId, subrequests, type, + }; + } + + // This is called by blueprint functions just before the subrequest is batched + // and actually sent to the server. This way, dependencies from earlier batches + // of requests can be resolved to static values, and concurrent dependencies + // (ie, dependencies that will be part of the same batch) can be suitably + // formatted according to the JSONPath spec. + function resolveDependencies(fieldData, ready, prior) { + const { + bundle, dependencies, entity, requestId, + } = fieldData; + const schema = farm.schema.get(entity, bundle); + + // Resolved fields include data from prior request batches, as well as concurrent + // requests that do not require a post hoc subrequest. + const resolved = {}; + // The names of unresolved fields will be collected, but are ultimately left + // to the blueprint functions to handle independently. + const unresolved = []; + // Post hoc subrequests are concurrent blueprints for creating relationships + // that must be added to the entity separately from the main 'create' + // subrequest, using the entity's relationship endpoint. This is likely b/c + // the dependency uses a JSONPath wildcard, which can't be resolved to a + // resource identifier w/o a separate subrequest. + const posthoc = []; + // Only concurrent reqIds are added to waitFor, which is why it is done here + // and is not the sole responsibility of the commands. + const waitFor = []; + + // Match a requestId with its corresponding prior responses, if any exist. + // The contentId is included in each response object, so just use the values. + const matchWithResponses = reqId => Object.values(prior) + .filter(sub => reqId === sub.requestId); + // Field dependencies, as an array of raw requestIds, are mapped to an array + // of corresponding resource identifiers from prior responses. + const mapDependenciesToResources = compose( + // Take only the first occurrence of the resource, which will be from the + // most recent request since they've already been sorted by priority. + uniqBy(prop('id')), + // Strip it down to the resource identifier alone. + map(pick(['id', 'type'])), + rFilter((data) => data && 'id' in data && 'type' in data), + // Flatten the array of nested data objects/arrays. + chain(path(['body', 'data'])), + // Responses are sorted by highest priority, which corresponds to the most recent. + // In the future it may also be useful to parse the requestId as a tie-breaker. + sort((a, b) => a.priority - b.priority), + // The array of requestIds, are each flatmapped to an array of matching responses. + chain(matchWithResponses), + ); + + // Iterate over all dependencies, resolving their corresponding fields using + // the most up-to-date (highest priority) data available. + Object.entries(dependencies).forEach(([field, fieldDeps]) => { + // Based on JSON Schema types, a field has a relation type of 'object' for + // ONE-TO-ONE relationships, or 'array' for ONE-TO-MANY relationships. + const relation = typeOfRelationship(schema, field); + const depPrefix = `${requestId}.${field}::`; + const trimPrefix = d => d.replace(depPrefix, ''); + const isDirect = d => !trimPrefix(d).includes('::'); + const directDeps = fieldDeps.filter(isDirect); + const concurrentIds = Object.keys(ready).filter(id => directDeps.includes(id)); + const resources = mapDependenciesToResources(directDeps); + // ONE-TO-ONE RELATIONSHIPS + if (relation === 'object') { + // Concurrent dependencies are taken first b/c they will be the most recent. + if (concurrentIds.length > 0) { + // Taking the first result, whether there's one or many and w/o + // sorting by priority b/c they will all be the same priority. + const [reqId] = concurrentIds; + // These are the only request ids that this request needs to wait for; + // all others can be resolved or, in the case of one-to-many relationships, + // they will be added independently as post hoc requests. + waitFor.push(reqId); + const resource = { + id: `{{${reqId}.body@$.data.id}}`, + type: path([reqId, 'type'], ready), + }; + resolved[field] = resource; + } else if (resources.length > 0) { + const [resource] = resources; + resolved[field] = resource; + } else { + unresolved.push(field); + } + // ONE-TO-MANY RELATIONSHIPS + } else if (relation === 'array') { + // Concurrent dependencies will be updated in a separate subrequest sent + // to the parent entity's relationships enpoint; that subrequest will be + // in the same batch, but subsequent to the original subrequest; hence, + // they are separated as "posthoc" (ie, "after the fact") dependencies. + concurrentIds.forEach((reqId) => { + const uuid = `{{${requestId}.body@$.data.id}}`; + const dataToken = parseDataToken(reqId); + const data = [{ + id: `{{${dataToken}.id}}`, + type: `{{${dataToken}.type}}`, + // The target's revision id is required primarily for quantities, + // but it doesn't hurt to add it for all others as well. + meta: { + target_revision_id: `{{${dataToken}.attributes.drupal_internal__revision_id}}`, + drupal_internal__target_id: `{{${dataToken}.attributes.drupal_internal__id}}`, + }, + }]; + const blueprint = { + requestId: `${requestId}.${field}`, + // Note the entity's relationship endpoint is used here. + uri: `${BASE_URI}/${entity}/${bundle}/${uuid}/relationships/${field}`, + waitFor: [reqId, requestId], + action: 'create', + headers, + body: JSON.stringify({ data }), + }; + posthoc.push(blueprint); + }); + // Only dependencies from prior request batches can be fully resolved. + resolved[field] = resources; + } else { + console.warn(`Uknown field in subrequest for ${bundle} ${entity}: ${field}`); + } + }); + + return { + resolved, posthoc, unresolved, waitFor, + }; + } + + // Convert an entity or array of entities into Drupal format, then stringify. + function fmtLocalData(raw) { + const transforms = generateFieldTransforms(farm.schema.get()); + const data = Array.isArray(raw) + ? raw.map(d => transformLocalEntity(d, transforms)) + : transformLocalEntity(raw, transforms); + return JSON.stringify({ data }); + } + + // A safe way of calling entity methods, such as `farm.term.create()`. + function callEntityMethod(entity, method, ...args) { + if (typeof entity !== 'string' || !(entity in entities)) return null; + const { [entity]: { nomenclature: { shortName } } } = entities; + if (!shortName || typeof method !== 'string') return null; + const { [shortName]: { [method]: fn } } = farm; + if (typeof fn !== 'function') return null; + return fn(...args); + } + + const commands = { + $create(fields, prefix) { + const fieldData = parseDependentFields(fields, '$create', prefix); + const { + bundle, constants, dependencies, entity, priority, subrequests, requestId, type, + } = fieldData; + const blueprint = (ready, prior) => { + const { + resolved, posthoc, unresolved, waitFor, + } = resolveDependencies(fieldData, ready, prior); + unresolved.forEach((field) => { + const msg = `Unable to resolve ${field} field while creating ` + + `${bundle} ${entity}. Request ID: ${requestId}`; + console.warn(msg); + }); + const props = { ...resolved, ...constants }; + const action = 'create'; + const data = callEntityMethod(entity, 'create', props); + if (!data) return []; + const body = fmtLocalData(data); + const uri = `${BASE_URI}/${entity}/${bundle}`; + const current = { + action, body, headers, requestId, uri, waitFor, + }; + return [current, ...posthoc]; + }; + return { + ...subrequests, + [requestId]: { + blueprint, bundle, dependencies, entity, priority, type, + }, + }; + }, + $find(filter, prefix, opts) { + const { $createIfNotFound, $limit, $sort } = opts; + const schemata = farm.schema.get(); + const filterTransforms = generateFieldTransforms(schemata); + const validTypes = Object.entries(schemata) + .flatMap(([e, s]) => Object.keys(s).map(b => `${e}--${b}`)); + const filtersByType = splitFilterByType(filter, validTypes); + + const subrequests = {}; + filtersByType.forEach(({ type, filter: typeFilter }) => { + const { entity, bundle } = parseEntityType(type); + // The initial fetch request with filter & other search parameters. + const requestId = `${prefix}::$find:${type}`; + // Nested subrequests are disallowed in $find commands, meaning they have + // no dependencies, and so should always be included in the first batch of + // subrequests; hence, they will always have a priority of 0. + const priority = 0; + const params = parseFetchParams({ + filter: typeFilter, filterTransforms, limit: $limit, sort: $sort, + }); + const blueprint = () => [{ + action: 'view', + headers, + requestId, + uri: `${BASE_URI}/${entity}/${bundle}?${params}`, + }]; + subrequests[requestId] = { + blueprint, bundle, entity, priority, type, + }; + }); + + if (!$createIfNotFound || validTypes.length < 1) return subrequests; + + // Arbitrarily pop the first type. + const [{ type, filter: typeFilter }] = filtersByType; + const { entity, bundle } = parseEntityType(type); + const requestId = `${prefix}::$createIfNotFound:${type}`; + // A list of request ids is created before the above requestId is added to + // the subrequests object, and before the blueprint is evaluated. + const findRequestIds = Object.keys(subrequests); + // A separate create request is only added when $createIfNotFound is true; + // Even then, its blueprint returns the empty array (ie, no-op), + // unless all prior find requests come back empty. + const blueprint = (_, prior) => { + const results = Object.values(prior) + .filter(sub => findRequestIds.includes(sub.requestId) && sub.body) + .flatMap((sub) => sub.body.data || []); + if (results.length > 0) return []; + const props = { ...dropKeywords(typeFilter), type }; + const data = callEntityMethod(entity, 'create', props); + if (!data) return []; + return [{ + action: 'create', + body: fmtLocalData(data), + headers, + requestId, + uri: `${BASE_URI}/${entity}/${bundle}`, + }]; + }; + // Because $find requests are always priority 0, this will always be 1. + const priority = 1; + subrequests[requestId] = { + blueprint, bundle, entity, priority, type, + }; + return subrequests; + }, + }; + + function parseSubrequest(subrequest = {}, options = {}, prefix = '$ROOT') { + const [[k, v], ...rest] = Object.entries(subrequest); + let opts = { ...options, ...Object.fromEntries(rest) }; + if (k in commands) return commands[k](v, prefix, opts); + if (rest.length === 0) { + const joinedOpts = Object.keys(options).join(', '); + const joinedActs = Object.keys(commands).join(', '); + const msg = `Missing or invalid command in subrequest at ${prefix}. ` + + `Only the following options or keys were included: ${joinedOpts}. ` + + `Include one of the following valid commands instead: ${joinedActs}.`; + throw new Error(msg); + } + if (isKeyword(k)) opts = { ...opts, [k]: v }; + return parseSubrequest(Object.fromEntries(rest), opts, prefix); + } + + function chainSubrequests(requests, responses = [], priority = 0) { + // Separate requests with the current priority level from all later requests. + const [ready, next] = partition(r => r.priority === priority, requests); + // Each response contains one "batch" (ie, the same priority) of subresponses + // but blueprint functions expect one big object containing all priorities. + const mergeResponses = compose( + // Using mergeRight as the reducer merges all batches of subresponses into a + // single object. Because they're keyed to contentId, they shouldn't collide. + reduce(mergeRight, {}), + // The data property on each response object contains the subresponses. + map(prop('data')), + ); + const prior = mergeResponses(responses); + + // Determine if a request (req) depends upon another separate request, + // based on the former's dependencies and the latter's requestId (id). + const isDependent = (req, id) => { + const { dependencies = {} } = req; + const allDeps = Object.values(dependencies).flat(); + return allDeps.includes(id); + }; + // Even once ready requests have been partitioned from pending ones, they + // must still be sorted according to which ones depend upon each other. This + // prevents subrequests from referencing dependencies that have resolved to + // a 'noop' will be removed from the collection of concurrent subrequests. + function sortByDependencies(unsorted, sorted = []) { + const [head, ...tail] = unsorted; + if (tail.length < 1) return [...sorted, head]; + const [idA, reqA] = head; + const aDependsOn = []; const dependsOnA = []; const independent = []; + tail.forEach(([idB, reqB]) => { + if (isDependent(reqA, idB)) { + aDependsOn.push([idB, reqB]); + } else if (isDependent(reqB, idA)) { + dependsOnA.push([idB, reqB]); + } else { + independent.push([idB, reqB]); + } + }); + const circularDependency = !aDependsOn.every(([i]) => dependsOnA.every(([j]) => i !== j)); + if (circularDependency) throw new Error('Circular dependency detected!'); + if (aDependsOn.length === 0) { + return sortByDependencies(tail, [...sorted, head]); + } + const resorted = [...independent, ...aDependsOn, head, ...dependsOnA]; + return sortByDependencies(resorted, sorted); + } + + // Once they've been sorted, the blueprints of ready requests must be evaluated + // sequentially, and if their blueprint is empty (ie, a 'noop'), they must be + // removed from the final list of concurrent requests, b/c leaving them in can + // result in a server error if it encounters a requestId it doesn't recognize. + const concatConcurrentRequests = reduce((concurrent, [reqId, req]) => { + const resolved = evolve({ + blueprint: bp => bp(concurrent, prior), + }, req); + if (resolved.blueprint.length < 1) return concurrent; + return assoc(reqId, resolved, concurrent); + }, {}); + // Combine all the above steps to take a batch of ready requests and return + // the final blueprint array to send to the server's subrequest endpoint. + const resolveBlueprints = compose( + chain(prop('blueprint')), + Object.values, + concatConcurrentRequests, + sortByDependencies, + Object.entries, + ); + function pruneUnmetDependencies(blueprint) { + const requestIds = blueprint.map(b => b.requestId); + const pruned = blueprint.filter(({ waitFor = [] }) => + waitFor.length < 1 || waitFor.every(id => requestIds.includes(id))); + if (pruned.length === blueprint.length || pruned.length === 0) return pruned; + return pruneUnmetDependencies(pruned); + } + const concatBlueprints = compose( + pruneUnmetDependencies, + resolveBlueprints, + ); + + // Merge a batch of subresponses w/ their original request data. + const mergeResponseWithRequest = evolve({ + // The data property on the main response object contains the subresponses, + // each keyed to their contentId. Map over each subresponse and match it with + // its corresponding request, merge them into one object, and parse the body. + data: mapObjIndexed(compose( + // Parse the body of each subresponse. + evolve({ body: JSON.parse }), + // Merge the request data with the response data. + sub => mergeRight(requests[sub.requestId], sub), + // Parse the contentId to get the original requestId, key and index, then + // merge those properties with the rest of the subresponse. + (sub, contentId) => mergeRight(parseContentId(contentId), sub), + )), + }); + + // Add an incoming response, along w/ its request data, to the list of + // prior responses, so they can be returned to the caller, or passed to the + // next recursive call of chainSubrequst. + const concatSubresponses = (response) => { + const merged = mergeResponseWithRequest(response); + return responses.concat(merged); + }; + + const data = concatBlueprints(ready); + const promise = farm.remote.request(SUB_URL, { method: 'POST', data }) + .then(concatSubresponses); + if (Object.keys(next).length === 0) return promise; + return promise.then(done => chainSubrequests(next, done, priority + 1)); + } + + return { + parse: parseSubrequest, + chain: chainSubrequests, + send(subrequest, data) { + const root = { data: { $ROOT: { data } } }; + const responses = []; + if (data) responses.push(root); + const requests = parseSubrequest(subrequest); + return chainSubrequests(requests, responses); + }, + }; +} diff --git a/src/client/type-to-bundle.js b/src/client/type-to-bundle.js deleted file mode 100644 index 19442f9..0000000 --- a/src/client/type-to-bundle.js +++ /dev/null @@ -1,4 +0,0 @@ -import replace from 'ramda/src/replace.js'; - -export default (entity, type) => - replace(`${entity}--`, '', type); diff --git a/src/entities.js b/src/entities.js index 45e3eac..362f9c0 100644 --- a/src/entities.js +++ b/src/entities.js @@ -20,7 +20,7 @@ export const defaultOptions = { byType, byFormat }; /** * @typedef {Object} Entity * @property {String} id A v4 UUID as specified by RFC 4122. - * @property {String} type Corresponding to the entity bundle (eg, 'activity'). + * @property {String} type The combined form of entity & bundle (eg, 'log--activity'). * @property {Object} attributes Values directly attributable to this entity. * @property {Object.>} relationships * References to other entities that define a one-to-one or one-to-many relationship. diff --git a/src/index.js b/src/index.js index 2d76eb7..4ad70a4 100644 --- a/src/index.js +++ b/src/index.js @@ -5,8 +5,10 @@ import defaultEntities from './entities.js'; export { default as entities } from './entities.js'; export { default as client } from './client/index.js'; -export { parseBundles } from './client/adapter/index.js'; +export { default as parseFilter } from './client/parse-filter.js'; +export { default as useSubrequests } from './client/subrequest.js'; export { default as model } from './model/index.js'; +export { splitFilterByType } from './types.js'; /** The methods for writing to local copies of farmOS data structures, such as * assets, logs, etc, and for transmitting those entities to a farmOS server. diff --git a/src/model/create.js b/src/model/create.js index 4ba1d6e..e6dd07f 100644 --- a/src/model/create.js +++ b/src/model/create.js @@ -1,6 +1,7 @@ import { validate, v4 as uuidv4 } from 'uuid'; import clone from 'ramda/src/clone.js'; import { getDefault, listProperties } from '../json-schema/index.js'; +import { parseTypeFromFields } from '../types.js'; /** * @typedef {import('../entities.js').Entity} Entity @@ -15,15 +16,15 @@ import { getDefault, listProperties } from '../json-schema/index.js'; * @returns {Entity} */ /** - * @param {string} entName * @param {import('./index.js').BundleSchemata} schemata * @returns {createEntity} */ -const createEntity = (entName, schemata, defaultOptions) => (props) => { - const { id = uuidv4(), type } = props; - if (!validate(id)) { throw new Error(`Invalid ${entName} id: ${id}`); } - const schema = schemata[entName][type]; - if (!schema) { throw new Error(`Cannot find a schema for the ${entName} type: ${type}.`); } +const createEntity = (schemata, defaultOptions) => (props) => { + const { id = uuidv4() } = props; + const { entity, bundle, type } = parseTypeFromFields(props); + if (!validate(id)) { throw new Error(`Invalid ${entity} id: ${id}`); } + const schema = schemata[entity] && schemata[entity][bundle]; + if (!schema) { throw new Error(`Cannot find a schema for the ${entity} type: ${type}.`); } const { attributes = {}, relationships = {}, meta = {}, ...rest } = clone(props); diff --git a/src/model/index.js b/src/model/index.js index 1adf738..772cd7b 100644 --- a/src/model/index.js +++ b/src/model/index.js @@ -6,7 +6,8 @@ import mergeEntity from './merge.js'; import updateEntity from './update.js'; import defaultEntities, { entityMethods } from '../entities.js'; import { dereference } from '../json-schema/reference.js'; -import { createObserver } from '../utils.js'; +import { parseEntityType } from '../types.js'; +import { createObserver, isObject } from '../utils.js'; /** * JSON Schema for defining the entities supported by a farmOS instance. @@ -66,7 +67,6 @@ import { createObserver } from '../utils.js'; */ export default function model(options = {}) { const { entities = defaultEntities } = options; - const entityNames = Object.keys(entities); const schemata = map(() => ({}), entities); const observers = { @@ -79,45 +79,68 @@ export default function model(options = {}) { * Retrieve all schemata that have been previously set, or the schemata of a * particular entity, or one bundle's schema, if specified. * @param {String} [entity] The name of a farmOS entity (eg, 'asset', 'log', etc). - * @param {String} [type] The entity's type (aka, bundle). + * @param {String} [bundle] The entity's bundle (eg, 'activity' for type 'log--activity'). * @returns {EntitySchemata|BundleSchemata|JsonSchemaDereferenced} */ - function getSchemata(entity, type) { - if (!entity) { - return clone(schemata); + function getSchemata(entity, bundle) { + if (schemata[entity] && schemata[entity][bundle]) { + return clone(schemata[entity][bundle]); } - if (!type) { + if (schemata[entity]) { return clone(schemata[entity]); } - return clone(schemata[entity][type]); + return clone(schemata); } /** * Load all schemata, the schemata of a particular entity, or one bundle's - * schema, if spcified. + * schema, if specified. * @param {...String|EntitySchemata|BundleSchemata|JsonSchema} args * @void */ function setSchemata(...args) { - if (args.length === 1) { - entityNames.forEach((entName) => { - if (entName in args[0]) { - setSchemata(entName, args[0][entName]); + if (args.length === 0) { + throw new Error('At least one valid argument is required for setting ' + + 'farm schemata but none was provided.'); + } + if (args.length === 1 && isObject(args[0])) { + const [entitySchemata] = args; + Object.entries(entitySchemata).forEach(([entity, bundleSchemata]) => { + if (entity in schemata) { + setSchemata(entity, bundleSchemata); } }); + return clone(schemata); } - if (args.length === 2) { - const [entName, newSchemata] = args; - if (entityNames.includes(entName)) { - Object.entries(newSchemata).forEach(([type, schema]) => { - setSchemata(entName, type, schema); - }); + if (args.length === 2 && isObject(args[1])) { + const type0 = parseEntityType(args[0]); + if (type0.entity in schemata && type0.bundle) { + const { entity, bundle } = type0; + const [, schema] = args; + setSchemata(entity, bundle, schema); + return clone(schemata)[entity][bundle]; } + const [entity, bundleSchemata] = args; + Object.entries(bundleSchemata).forEach(([type1, schema]) => { + const { bundle = type1 } = parseEntityType(type1); + setSchemata(entity, bundle, schema); + }); + Object.keys(schemata[entity]).forEach((bundle) => { + if (!(bundle in bundleSchemata)) delete schemata[bundle]; + }); + return clone(schemata)[entity]; } - if (args.length > 2) { - const [entName, type, schema] = args; - schemata[entName][type] = dereference(schema); + if (args.length === 3 + && args[0] in schemata + && typeof args[1] === 'string' + && isObject(args[2])) { + const [entity, type, schema] = args; + const { bundle = type } = parseEntityType(type); + schemata[entity][bundle] = dereference(schema); + return clone(schemata)[entity][bundle]; } + const description = 'One or more invalid arguments for setting farm schemata'; + throw new Error(`${description}: ${args.join(', ')}.`); } if (options.schemata) setSchemata(options.schemata); @@ -146,10 +169,10 @@ export default function model(options = {}) { return lastSync === null || changed > lastSync; }, }, - ...entityMethods(({ nomenclature: { name }, defaultOptions }) => ({ - create: createEntity(name, schemata, defaultOptions), - merge: mergeEntity(name, schemata), - update: updateEntity(name, schemata), + ...entityMethods(({ defaultOptions }) => ({ + create: createEntity(schemata, defaultOptions), + merge: mergeEntity(schemata), + update: updateEntity(schemata), }), entities), }; } diff --git a/src/model/merge.js b/src/model/merge.js index f2f2b15..27e2ebc 100644 --- a/src/model/merge.js +++ b/src/model/merge.js @@ -12,6 +12,7 @@ import map from 'ramda/src/map.js'; import prop from 'ramda/src/prop.js'; import createEntity from './create.js'; import { listProperties } from '../json-schema/index.js'; +import { parseTypeFromFields } from '../types.js'; // Helpers for determining if a set of fields are equivalent. Attributes are // fairly straightforward, but relationships need to be compared strictly by @@ -42,36 +43,36 @@ const eqFields = fieldType => * @returns {Entity} */ /** - * @param {string} entName * @param {import('./index.js').BundleSchemata} schemata * @returns {mergeEntity} */ -const mergeEntity = (entName, schemata) => (local, remote) => { +const mergeEntity = (schemata) => (local, remote) => { if (!remote) return clone(local); const now = new Date().toISOString(); if (!local) { // A nullish local value represents the first time a remotely generated // entity was fetched, so all changes are considered synced with the remote. const resetLastSync = evolve({ meta: { remote: { lastSync: () => now } } }); - return createEntity(entName, schemata)(resetLastSync(remote)); + return createEntity(schemata)(resetLastSync(remote)); } - const { id, type } = local; - if (!validate(id)) { throw new Error(`Invalid ${entName} id: ${id}`); } - const schema = schemata[entName][type]; + const { id } = local; + const { entity, bundle, type } = parseTypeFromFields(local); + if (!validate(id)) { throw new Error(`Invalid ${entity} id: ${id}`); } + const schema = schemata[entity] && schemata[entity][bundle]; if (!schema) { - throw new Error(`Cannot find a schema for the ${entName} type: ${type}.`); + throw new Error(`Cannot find a schema for the ${entity} type: ${type}.`); } const localName = local.attributes && `"${local.attributes.name || ''}" `; if (id !== remote.id) { - throw new Error(`Cannot merge remote ${entName} with UUID ${remote.id} ` - + `and local ${entName} ${localName}with UUID ${id}.`); + throw new Error(`Cannot merge remote ${entity} with UUID ${remote.id} ` + + `and local ${entity} ${localName}with UUID ${id}.`); } if (local.type !== remote.type) { - throw new Error(`Cannot merge remote ${entName} of type ${remote.type} ` - + `and local ${entName} ${localName}of type ${local.type}.`); + throw new Error(`Cannot merge remote ${entity} of type ${remote.type} ` + + `and local ${entity} ${localName}of type ${local.type}.`); } if (local.meta.conflicts.length > 0) { - throw new Error(`Cannot merge local ${entName} ${localName}` + throw new Error(`Cannot merge local ${entity} ${localName}` + 'while it still has unresolved conflicts.'); } diff --git a/src/model/update.js b/src/model/update.js index 87fa4b5..19f1cf0 100644 --- a/src/model/update.js +++ b/src/model/update.js @@ -1,6 +1,7 @@ import { validate } from 'uuid'; import clone from 'ramda/src/clone.js'; import { listProperties } from '../json-schema/index.js'; +import { parseTypeFromFields } from '../types.js'; /** * @typedef {import('../entities.js').Entity} Entity @@ -13,14 +14,14 @@ import { listProperties } from '../json-schema/index.js'; * @returns {Entity} */ /** - * @param {string} entName * @param {import('./index.js').BundleSchemata} schemata * @returns {updateEntity} */ -const updateEntity = (entName, schemata) => (entity, props) => { - const { id, type } = entity; +const updateEntity = (schemata) => (entity, props) => { + const { id } = entity; + const { entity: entName, bundle, type } = parseTypeFromFields(entity); if (!validate(id)) { throw new Error(`Invalid ${entName} id: ${id}`); } - const schema = schemata[entName][type]; + const schema = schemata[entName] && schemata[entName][bundle]; if (!schema) { throw new Error(`Cannot find a schema for the ${entName} type: ${type}.`); } const now = new Date().toISOString(); diff --git a/src/types.js b/src/types.js new file mode 100644 index 0000000..fc93114 --- /dev/null +++ b/src/types.js @@ -0,0 +1,96 @@ +import compose from 'ramda/src/compose'; +import match from 'ramda/src/match'; + +/** + * @type {RegExp} Identifies valid format for an entity type (eg 'log--activity) + * and groups matches by type, entity & bundle. + */ +export const entityTypeRegEx = /(\w+)--(\w+)/; +/** + * @type {Function} Validates a string as an entity type and parses it. + * @param {String} type A possible entity type (eg, 'log--activity'). + * @returns {{ type?: String, entity?: String, bundle?: String }} + * */ +export const parseEntityType = compose( + ([type, entity, bundle]) => ({ type, entity, bundle }), + match(entityTypeRegEx), +); +/** + * @type {() => Object} Takes any object containing entity data, such as props + * or fields, then normalizes the type, bundle & field. + * @param {{ type?: String, entity?: String, bundle?: string }} fields + * @returns {{ type?: String, entity?: String, bundle?: String }} + */ +export function parseTypeFromFields(fields = {}) { + let { entity, bundle, type } = fields; + if (type) ({ entity, bundle } = parseEntityType(type)); + if (!type && entity && bundle) type = `${entity}--${bundle}`; + return { entity, bundle, type }; +} + +/** + * @typedef {Array<{ type: String, filter: Object|Array }>} FiltersByType + */ +/** + * @param {Object|Array|Undefined} filter + * @param {Array} validTypes + * @returns {FiltersByType} + */ +export function splitFilterByType(filter, validTypes) { + /** @type {FiltersByType} */ + const filtersByType = []; + + // A plain array is equivalent to an object w/ an array as the `$or` property. + // In both cases, the array must itself contain valid filters, which can be + // evaluated recursively. + if (Array.isArray(filter.$or) || Array.isArray(filter)) { + (filter.$or || filter).forEach((f) => { + splitFilterByType(f, validTypes).forEach((tFilter) => { + // Instead of just adding every tFilter to tiltersByType, look for a + // matching filter that's already been added. + const tMatch = filtersByType.find(fbt => fbt.type === tFilter.type); + // If so, combine them into a single object w/ an array of filters. + if (tMatch) { + // The matching filter, the current filter, or both can be arrays, + // so concat onto an empty array to flatten them and reassign it. + tMatch.filter = [].concat(tMatch.filter, tFilter); + } else { + // Otherwise, add the whole object as-is. + filtersByType.push(tFilter); + } + }); + }); + return filtersByType; + } + + // The filter must either be an object (logical $and) or an array (logical $or). + // If it's neither, then it's not a valid filter, so return the empty array. + if (typeof filter !== 'object') return filtersByType; + + // Technically any object is equivalent to an object w/ an `$and` property, + // which is itself an object. Also, one type filter is not permitted to be + // nested under another, so we can safely pluck the type and ignore the rest. + const { type, ...rest } = typeof filter.$and === 'object' ? filter.$and : filter; + + // The case of filtering by a single type. + if (typeof type === 'string') { + if (!validTypes.includes(type)) return filtersByType; + filtersByType.push({ type, filter: rest }); + } + // The case of filtering by multiple types. + if (Array.isArray(type)) { + type.forEach((t) => { + if (validTypes.includes(t)) { + filtersByType.push({ type: t, filter: rest }); + } + }); + } + // An undefined or null type is interpreted as ALL types, so push the rest of + // the filter properties onto the array for each and every valid type. + if ([undefined, null].includes(type)) { + validTypes.forEach((t) => { + filtersByType.push({ type: t, filter: rest }); + }); + } + return filtersByType; +} diff --git a/test/client/schema.js b/test/client/schema.js index 25c24fd..23587a8 100644 --- a/test/client/schema.js +++ b/test/client/schema.js @@ -6,14 +6,29 @@ const { expect } = chai; describe('schema', function () { this.timeout(10000); - it('gets a schema for a specified bundle of a specified entity.', () => session() + it('gets the schema for a specified entity (\'log\') and bundle (\'activity\').', () => session() .then(() => farm.schema.fetch('log', 'activity')) .then((res) => { expect(res).to.have.nested.property('definitions.type.const', 'log--activity'); }).catch(reportError)); + it('gets the schema for a specified entity (\'log\') and type (\'log--activity\').', () => session() + .then(() => farm.schema.fetch('log', 'log--activity')) + .then((res) => { + expect(res).to.have.nested.property('definitions.type.const', 'log--activity'); + }).catch(reportError)); + it('gets the schema for a specified type (\'log--activity\').', () => session() + .then(() => farm.schema.fetch('log--activity')) + .then((res) => { + expect(res).to.have.nested.property('definitions.type.const', 'log--activity'); + }).catch(reportError)); it('gets schemata for all bundles of a specified entity.', () => session() .then(() => farm.schema.fetch('log')) .then((res) => { expect(res).to.have.nested.property('activity.definitions.type.const', 'log--activity'); }).catch(reportError)); + it('gets all schemata.', () => session() + .then(() => farm.schema.fetch()) + .then((res) => { + expect(res).to.have.nested.property('log.activity.definitions.type.const', 'log--activity'); + }).catch(reportError)); }); diff --git a/test/client/term.js b/test/client/term.js index efb8538..bf146a2 100644 --- a/test/client/term.js +++ b/test/client/term.js @@ -13,7 +13,7 @@ describe('term', function () { .then(() => { const term = { id, - type: 'taxonomy_term--material', + type: 'taxonomy_term--material_type', attributes: { name: 'Node Test', }, @@ -24,7 +24,7 @@ describe('term', function () { expect(response).to.have.nested.property('data.data.id', id); const term = { id, - type: 'taxonomy_term--material', + type: 'taxonomy_term--material_type', attributes: { name: 'Node Test Revised', }, diff --git a/test/index.js b/test/index.js index d55d7a0..aabdfb5 100644 --- a/test/index.js +++ b/test/index.js @@ -23,14 +23,14 @@ describe('farmOS', function () { .then((res) => { farm.schema.set(res); const season = farm.schema.get('taxonomy_term', 'season'); - expect(season).to.have.nested.property('properties.type.const', 'season'); + expect(season).to.have.nested.property('properties.type.const', 'taxonomy_term--season'); }) .catch(reportError)); it('create an activity log, send it to the server and delete it', () => { - const activity = farm.log.create({ type: 'activity', name: 'did some stuff' }); + const activity = farm.log.create({ type: 'log--activity', name: 'did some stuff' }); const { id } = activity; return farm.log.send(activity) - .then(() => farm.log.fetch({ filter: { type: 'activity', id } })) + .then(() => farm.log.fetch({ filter: { type: 'log--activity', id } })) .then(({ data: [remoteActivity] }) => { const updatedActivity = farm.log.update(activity, { name: 'did some more stuff' }); const mergedActivity = farm.log.merge(updatedActivity, remoteActivity); @@ -41,7 +41,7 @@ describe('farmOS', function () { expect(nameChangedAfterStatus).to.be.true; return farm.log.delete('activity', id); }) - .then(() => farm.log.fetch({ filter: { type: 'activity', id } })) + .then(() => farm.log.fetch({ filter: { type: 'log--activity', id } })) .then((results) => { expect(results.data).to.have.lengthOf(0); }) diff --git a/test/model/create.js b/test/model/create.js index 1eac4de..3cbdcef 100644 --- a/test/model/create.js +++ b/test/model/create.js @@ -33,16 +33,16 @@ const keys = { describe('log', () => { describe('#create', () => { it('creates a valid activity log', () => { - const activity = farm.log.create({ type: 'activity', name: 'my first log' }); + const activity = farm.log.create({ type: 'log--activity', name: 'my first log' }); expect(validate(activity.id)).to.be.true; - expect(activity.type).to.equal('activity'); + expect(activity.type).to.equal('log--activity'); expect(activity.attributes).to.have.all.keys(keys.attributes); expect(activity.relationships).to.have.all.keys(keys.relationships); expect(Date.parse(activity.attributes.timestamp)).to.not.be.NaN; }); it('throws if no valid type is provided', () => { - expect(() => farm.log.create({ name: 'bad log' })).to.throw('log type: undefined'); - expect(() => farm.log.create({ type: 'foo', name: 'bad log' })).to.throw('log type: foo'); + expect(() => farm.log.create({ name: 'bad log' })).to.throw(); + expect(() => farm.log.create({ type: 'foo', name: 'bad log' })).to.throw(); }); }); }); diff --git a/test/model/merge.js b/test/model/merge.js index 627ae64..046e8d2 100644 --- a/test/model/merge.js +++ b/test/model/merge.js @@ -10,7 +10,7 @@ describe('log', () => { describe('#merge', () => { it('merges a remote log with no conflicts', (done) => { // Start with a log created locally. - const local = farm.log.create({ type: 'activity', name: 'local log' }); + const local = farm.log.create({ type: 'log--activity', name: 'local log' }); setTimeout(() => { // Simulate syncing it to a remote system, which then updates it. const remote = farm.log.merge(undefined, local); diff --git a/test/model/schema.js b/test/model/schema.js index b1b44ad..b7790a7 100644 --- a/test/model/schema.js +++ b/test/model/schema.js @@ -54,9 +54,9 @@ describe('schema', () => { 'transplanting', 'foo', ); - const foo = farm.log.create({ type: 'foo', name: 'my foo log' }); + const foo = farm.log.create({ type: 'log--foo', name: 'my foo log' }); expect(foo.attributes.name).to.equal('my foo log'); - expect(foo.type).to.equal('foo'); + expect(foo.type).to.equal('log--foo'); }); }); }); diff --git a/test/model/update.js b/test/model/update.js index 5485777..ce0ca52 100644 --- a/test/model/update.js +++ b/test/model/update.js @@ -12,7 +12,7 @@ describe('log', () => { this.timeout(1500); const delay = 1000; const tolerance = 100; - const activity = farm.log.create({ type: 'activity', name: 'a log' }); + const activity = farm.log.create({ type: 'log--activity', name: 'a log' }); setTimeout(() => { const updatedActivity = farm.log.update(activity, { name: 'an updated log' }); expect(updatedActivity.attributes.name).to.be.equal('an updated log'); diff --git a/test/subrequest.js b/test/subrequest.js new file mode 100644 index 0000000..30b5136 --- /dev/null +++ b/test/subrequest.js @@ -0,0 +1,151 @@ +const chai = require('chai'); +const { mergeRight } = require('ramda'); +const farmOS = require('../dist/cjs/farmOS').default; +const { useSubrequests } = require('../dist/cjs/farmOS'); +const localServerConfig = require('../local-server-config'); +const { reportError } = require('./report'); + +const { expect } = chai; +const { + host, clientId, username, password, +} = localServerConfig; +const remote = { host, clientId }; + +const sub1 = { + $create: { + type: 'log--input', + name: 'west field bed 12', + location: { + $find: { + type: 'asset--land', + status: 'active', + land_type: 'bed', + name: 'west field bed 12', + is_location: true, + }, + $limit: 1, + $createIfNotFound: true, + }, + category: { + $find: { + type: 'taxonomy_term--log_category', + name: 'pest_disease_control', + }, + $limit: 1, + $createIfNotFound: true, + }, + owner: { + $find: { + type: 'user--user', + mail: 'admin@our-sci.net', + }, + $limit: 1, + $createIfNotFound: true, + }, + quantity: { + $create: { + type: 'quantity--standard', + label: 'hhh', + measure: 'weight', + units: { + $find: { + type: 'taxonomy_term--unit', + name: 'US_gal_acre', + }, + $sort: { + weight: 'DESC', + }, + $limit: 1, + $createIfNotFound: true, + }, + }, + }, + }, +}; + +describe.skip('subrequest', function () { + this.timeout(10000); + const farm = farmOS({ remote }); + const session = farm.remote.authorize(username, password); + const subrequests = useSubrequests(farm); + before(() => session.then(() => farm.schema.fetch()).then(farm.schema.set)); + it('parses a subrequest', () => { + const parsed = subrequests.parse(sub1); + const requestIds = [ + '$ROOT::$create:log--input.location::$find:asset--land', + '$ROOT::$create:log--input.location::$createIfNotFound:asset--land', + '$ROOT::$create:log--input.category::$find:taxonomy_term--log_category', + '$ROOT::$create:log--input.category::$createIfNotFound:taxonomy_term--log_category', + '$ROOT::$create:log--input.owner::$find:user--user', + '$ROOT::$create:log--input.owner::$createIfNotFound:user--user', + '$ROOT::$create:log--input.quantity::$create:quantity--standard.units::$find:taxonomy_term--unit', + '$ROOT::$create:log--input.quantity::$create:quantity--standard.units::$createIfNotFound:taxonomy_term--unit', + '$ROOT::$create:log--input.quantity::$create:quantity--standard', + '$ROOT::$create:log--input', + ]; + expect(parsed).to.have.all.keys(requestIds); + }); + it('sends a request with multiple subrequests', () => subrequests.send(sub1) + .then((responses) => { + const requestIds = [ + '$ROOT::$create:log--input', + '$ROOT::$create:log--input.quantity::$create:quantity--standard', + '$ROOT::$create:log--input.category::$find:taxonomy_term--log_category', + '$ROOT::$create:log--input.category::$createIfNotFound:taxonomy_term--log_category', + '$ROOT::$create:log--input.location::$find:asset--land', + '$ROOT::$create:log--input.location::$createIfNotFound:asset--land', + '$ROOT::$create:log--input.owner::$find:user--user', + '$ROOT::$create:log--input.owner::$createIfNotFound:user--user', + '$ROOT::$create:log--input.quantity::$create:quantity--standard.units::$find:taxonomy_term--unit', + '$ROOT::$create:log--input.quantity::$create:quantity--standard.units::$createIfNotFound:taxonomy_term--unit', + ]; + const subresponses = responses.map(sub => sub.data).reduce(mergeRight, {}); + expect(subresponses).to.have.all.keys(requestIds); + + const [inputRequestId, quantRequestId] = requestIds; + + expect(subresponses).to.include.key(inputRequestId); + const inputSubresponse = subresponses[inputRequestId]; + const { body: { data: input } = {} } = inputSubresponse; + expect(input).to.have.property('type', 'log--input'); + expect(input).to.have.nested.property('attributes.name', 'west field bed 12'); + + /** + * This test currently fails b/c the server's relationship endpoint is broken. + * {@see https://github.com/farmOS/farmOS.js/blob/d1d4ee3913ba9017ec125ab3d2aa2f0a9bf88631/src/client/subrequest.js#L184-L202} + * {@see https://jsonapi.org/format/#crud-updating-to-many-relationships} + */ + // expect(input).to.have.nested.property('relationships.category.data') + // .that.has.lengthOf(1); + + expect(subresponses).to.include.key(quantRequestId); + const quantSubresponse = subresponses[quantRequestId]; + const { body: { data: quantity } = {} } = quantSubresponse; + expect(quantity).to.have.property('type', 'quantity--standard'); + expect(quantity).to.have.nested.property('attributes.label', 'hhh'); + }) + .catch(reportError)); + this.afterAll(() => Promise.all([ + farm.log.fetch({ filter: { type: 'log--input', name: 'west field bed 12' } }), + farm.quantity.fetch({ filter: { type: 'quantity--standard', label: 'hhh' } }), + farm.asset.fetch({ filter: { type: 'asset--land', name: 'west field bed 12' } }), + farm.term.fetch({ filter: { type: 'taxonomy_term--log_category', name: 'pest_disease_control' } }), + farm.term.fetch({ filter: { type: 'taxonomy_term--unit', name: 'US_gal_acre' } }), + ]).then(([logs, quantities, assets, categories, units]) => { + const cleanup = (shortName, bundle, response) => { + const ids = response.data.map(d => d.id); + return ids.map(id => farm[shortName].delete(bundle, id)); + }; + const round1 = Promise.all([ + cleanup('log', 'input', logs), + cleanup('quantity', 'standard', quantities), + cleanup('asset', 'land', assets), + ].flat()); + // These terms can only be deleted AFTER the entities referencing them have been. + const round2 = () => Promise.all([ + cleanup('term', 'log_category', categories), + cleanup('term', 'unit', units), + ].flat()); + return round1.then(round2); + })); +});