UNPKG

docorm

Version:

Persistence layer with ORM features for JSON documents

369 lines 18.4 kB
import _ from 'lodash'; import { InternalError } from './errors.js'; import { importDirectory } from './import-dir.js'; import { pathDepth } from './paths.js'; const schemaDirectories = []; /** * * @param path - The directory path containing schemas. * @param schemaType - The schema type * @param namespace */ export async function registerSchemaDirectory(path, schemaType, namespace = undefined) { const schemas = await importDirectory(path, { recurse: true, extensions: ['json'], assertType: 'json' }); schemaDirectories.push({ namespace, schemaType, path, schemas }); } export function getSchema(path, schemaType, namespace = undefined) { const pathArr = _.isArray(path) ? path : path.split('.'); for (const dir of schemaDirectories.filter((d) => d.schemaType == schemaType && d.namespace == namespace)) { const schema = _.get(dir.schemas, pathArr, null); if (schema) { return schema; } } return null; } const INITIAL_MAKE_SCHEMA_CONCRETE_STATE = { knownConcreteSubschemas: {} }; // TODO When expanding $refs, allow circularity. Concrete schemas can be circular; we must handle serialization and // deserialization appropriately. export function makeSchemaConcrete(schema, schemaType = 'model', namespace = undefined, state = INITIAL_MAKE_SCHEMA_CONCRETE_STATE) { const { knownConcreteSubschemas, cachedSchemaKeyToStore } = state; let concreteSchema = {}; if (cachedSchemaKeyToStore) { state.knownConcreteSubschemas[cachedSchemaKeyToStore] = concreteSchema; } if (schema.allOf) { const concreteSubschemas = schema.allOf.map((subschema) => makeSchemaConcrete(subschema, schemaType, namespace, { knownConcreteSubschemas })); const invalidSubschemaIndex = concreteSubschemas.findIndex((s) => s.type != 'object'); if (invalidSubschemaIndex >= 0) { // Handle error } const requiredProperties = _.flatten(concreteSubschemas.map((s) => s.required || [])); const properties = Object.assign({}, ...concreteSubschemas.map((s) => s.properties || {})); // TODO Validate the properties once merged? Object.assign(concreteSchema, { type: 'object', required: _.isEmpty(requiredProperties) ? undefined : requiredProperties, properties: _.isEmpty(properties) ? undefined : properties }); } else if (schema.oneOf) { // TODO To properly handle anyOf and oneOf, we need to let them remain in the concrete schema. Since our concrete // schema implementation doesn't support this yet, we currently treat them just like allOf, but we ignore // required properties. const concreteSubschemas = schema.oneOf.map((subschema) => makeSchemaConcrete(subschema, schemaType, namespace, { knownConcreteSubschemas })); const invalidSubschemaIndex = concreteSubschemas.findIndex((s) => s.type != 'object'); if (invalidSubschemaIndex >= 0) { // Handle error } const requiredProperties = []; // _.flatten(concreteSubschemas.map((s) => s.required || [])) const properties = Object.assign({}, ...concreteSubschemas.map((s) => s.properties || {})); // If the schema has oneOf at the top level, check each of the options. If any option describes a relationship // with an entity type or storage method (like 'ref' or 'inverse-ref'), copy those properties to the top level. // This ensures that we can expand references even when they occur as oneOf options. // TODO This approach is a kluge. We should ultimately distinguish between fully concrete schemas (which don't // have refs or storage) and unexpanded concrete schemas (which do). const refProperties = _.merge({}, ...schema.oneOf.map((subschema) => _.pick(subschema, ['storage', 'foreignKey', 'entityType']))); // TODO Validate the properties once merged? Object.assign(concreteSchema, { type: 'object', ...refProperties, required: _.isEmpty(requiredProperties) ? undefined : requiredProperties, properties: _.isEmpty(properties) ? undefined : properties }); } else if (schema.$ref) { const path = _.trimStart(schema.$ref, '/').split('/'); const cachedSchemaKey = `${schema.$ref}|${schema.entityType}|${schema.foreignKey}|${schema.storage}`; if ((state.knownConcreteSubschemas[cachedSchemaKey] != null) && (cachedSchemaKey != cachedSchemaKeyToStore)) { concreteSchema = knownConcreteSubschemas[cachedSchemaKey]; if (cachedSchemaKeyToStore) { state.knownConcreteSubschemas[cachedSchemaKeyToStore] = concreteSchema; } } else { // TODO Handle error if not found const subschema = getSchema(path, schemaType, namespace); if (!subschema) { throw new InternalError(`Schema refers to unknown subschema with $ref "${path}".`); } Object.assign(concreteSchema, makeSchemaConcrete(subschema, schemaType, namespace, { knownConcreteSubschemas, cachedSchemaKeyToStore: cachedSchemaKey })); } if (schema.entityType || schema.foreignKey || schema.storage) { Object.assign(concreteSchema, _.pick(schema, ['entityType', 'foreignKey', 'storage'])); } // Add $ref to the concrete subschema's properties. This is workaround, whereas what we really want to do is stop // producing a concrete schema in advance for a type; instead we might produce concrete schemas only when needed, // based on a schema context and taking into account what relationships have been expanded. // TODO If concreteSchema's type is not object, this doesn't make sense. In fact we should only allow $refs to // object schemas. if (concreteSchema.type == 'object') { concreteSchema.properties ||= {}; if (!concreteSchema.properties.$ref) { concreteSchema.properties.$ref = { type: 'string' }; } } } else { switch (schema.type) { case 'object': { Object.assign(concreteSchema, _.clone(schema)); if (concreteSchema.properties) { concreteSchema.properties = _.mapValues(concreteSchema.properties, (p) => makeSchemaConcrete(p, schemaType, namespace, { knownConcreteSubschemas })); } } break; case 'array': { Object.assign(concreteSchema, _.clone(schema)); if (concreteSchema.items) { concreteSchema.items = makeSchemaConcrete(concreteSchema.items, schemaType, namespace, { knownConcreteSubschemas }); } } break; default: Object.assign(concreteSchema, schema); } } return concreteSchema; } export function findPropertyInSchema(schema, path) { const schemaType = schema.type; if (!_.isArray(path)) { path = path.split('.'); } if (path.length == 0) { // TODO Warn about an invalid path return null; } switch (schemaType) { case 'object': { const subschema = _.get(schema, ['properties', path[0]], null); if ((path.length == 1) || (subschema == null)) { return subschema; } else { return findPropertyInSchema(subschema, _.slice(path, 1)); } } case 'array': { const subschema = _.get(schema, ['items'], null); if (subschema == null) { // TODO Warn about missing items in schema return null; } else { return findPropertyInSchema(subschema, _.slice(path, 1)); } } default: // TODO Warn that we're trying to find a property in a non-object schema. return null; } } /* * Find all related items reference by ID. * currentPath is a JSONPath */ export function findRelationships(schema, allowedStorage, maxDepth = undefined, currentPath = '$', nodesTraversedInPath = [], depthFromParent = 0) { if (maxDepth == undefined && nodesTraversedInPath.includes(schema)) { // If no maximum depth was specified, do not traverse circular references. // TODO This does not seem to work. nodesTraversedInPath.includes(schema) isn't catching the circularity. return []; } else if (maxDepth != undefined && pathDepth(currentPath) > maxDepth) { // If we have exceeded the maximum depth, stop traversing the schema. return []; } let relationships = []; const schemaType = schema.type; const oneOf = schema.oneOf; if (oneOf && _.isArray(oneOf)) { // This case does not actually arise right now, since we don't allow concrete schemas to use oneOf. for (const subschema of oneOf) { relationships = relationships.concat(findRelationships(subschema, allowedStorage, maxDepth, `${currentPath}`, [...nodesTraversedInPath, schema], depthFromParent)); } } else { switch (schemaType) { case 'object': { const entityTypeName = schema.entityType; const storage = schema.storage; const objectIsReference = entityTypeName && storage && ['ref', 'inverse-ref'].includes(storage); if (entityTypeName && storage && (!allowedStorage || allowedStorage.includes(storage))) { const relationship = { path: currentPath, toMany: false, storage: storage || 'copy', entityTypeName, schema, depthFromParent }; if (storage == 'inverse-ref') { const foreignKeyPath = schema.foreignKey; if (!foreignKeyPath) { // TODO Include the current location in the logged error. throw new InternalError(`Missing foreign key path in relationship with storage type inverse-ref`); } relationship.foreignKeyPath = foreignKeyPath; } relationships.push(relationship); } const propertySchemas = _.get(schema, ['properties'], []); for (const property of _.keys(propertySchemas)) { const subschema = propertySchemas[property]; relationships = relationships.concat(findRelationships(subschema, allowedStorage, maxDepth, `${currentPath}.${property}`, [...nodesTraversedInPath, schema], objectIsReference ? 0 : depthFromParent + 1)); } } break; case 'array': { const itemsSchema = schema?.items; if (itemsSchema) { const entityTypeName = itemsSchema.entityType; const storage = itemsSchema.storage; const itemsAreReferences = entityTypeName && storage && ['ref', 'inverse-ref'].includes(storage); if (entityTypeName && storage && (!allowedStorage || allowedStorage.includes(storage))) { const relationship = { path: currentPath, toMany: true, storage: storage || 'copy', entityTypeName, schema: itemsSchema, depthFromParent }; if (storage == 'inverse-ref') { const foreignKeyPath = itemsSchema.foreignKey; if (!foreignKeyPath) { // TODO Include the current location in the logged error. throw new InternalError(`Missing foreign key path in relationship with storage type inverse-ref`); } relationship.foreignKeyPath = foreignKeyPath; } relationships.push(relationship); } const itemsSchemaWithoutStorage = _.omit(itemsSchema, 'storage'); relationships = relationships.concat(findRelationships(itemsSchemaWithoutStorage, allowedStorage, maxDepth, `${currentPath}[*]`, [...nodesTraversedInPath, schema], itemsAreReferences ? 0 : depthFromParent + 1)); } } break; default: break; } } return relationships; } /** * Find all related item definitions along one path in a concrete schema. */ // TODO Needs adjustment to handle related items within arrays. // TODO Do we really need this? If so, could we filter the results of findRelatedItemsInSchema? export function findRelationshipsAlongPath(schema, path, allowedStorage, currentPath = []) { let relationships = []; const schemaType = schema.type; if (!_.isArray(path)) { path = path.split('.'); } if (path.length == 0) { // TODO Warn about an invalid path return relationships; } switch (schemaType) { case 'object': { const entityTypeName = schema.entityType; const storage = schema.storage; if (entityTypeName && storage && (!allowedStorage || allowedStorage.includes(storage))) { relationships.push({ path: currentPath.join('.'), toMany: false, storage: storage || 'copy', entityTypeName, schema, depthFromParent: 0 // TODO Populate this correctly. }); } // Whether or not the object is a relationship, continue traversing the path. const propertySchema = _.get(schema, ['properties', path[0]], null); if (!propertySchema) { // TODO Warn about property in the path that is not in the schema. } else { relationships = relationships.concat(findRelationshipsAlongPath(propertySchema, _.slice(path, 1), allowedStorage, [...currentPath, path[0]])); } } break; case 'array': { const itemsSchema = schema?.items; if (itemsSchema) { const entityTypeName = itemsSchema.entityType; const storage = itemsSchema.storage; if (entityTypeName && storage && (!allowedStorage || allowedStorage.includes(storage))) { relationships.push({ path: currentPath.join('.'), toMany: true, storage: storage || 'copy', entityTypeName, schema: itemsSchema, depthFromParent: 0 // TODO Populate this correctly. }); } // Whether or not the array is a relationship, continue traversing the path. const subschema = itemsSchema; relationships = relationships.concat(findRelationshipsAlongPath(itemsSchema, _.slice(path, 1), allowedStorage, [...currentPath, path[0]])); } // TODO Isn't this redundant? const itemSchema = _.get(schema, ['items'], null); if (!itemSchema) { // TODO Warn about array entry in the path that has no schema. } else { relationships = relationships.concat(findRelationshipsAlongPath(itemSchema, _.slice(path, 1), allowedStorage, [...currentPath, path[0]])); } } break; default: break; } return relationships; } /** * List all the transient properties of a concrete schema. Do not traverse relationships stored by reference. * * Transient properties are identified by the custom JSON schema attribute "custom". * * Because the schema must be concrete, it does not contain incorporate the contents of any other schemas; but it may * define entity-relationship properties that refer to other schemas. TODO Is this true? Clarify this point. * * @param {*} schema A concrete JSON schema (one that does not contain any references to other schemas). * @param {propertyPathElement[]} [currentPath=[]] - A path to a subschema to catalogue, used when this function calls * itself recursively. The default value is an empty path, indicating that the whole schema shoulc be catalogued from * its root down. * @return {string[]} A list of transient property paths in dot notation. */ export function listTransientPropertiesOfSchema(schema, currentPath = []) { let transientFieldPaths = []; const schemaType = schema.type; const transient = schema.transient; const properties = schema.properties; if (transient && currentPath.length > 0) { // The root of a schema cannot be transient. transientFieldPaths = [currentPath.join('.')]; } else if (schemaType == 'object') { transientFieldPaths = _.flatten(_.map(properties, (subschema, name) => (subschema.storage == 'ref') ? [] : listTransientPropertiesOfSchema(subschema, currentPath.concat([name])))); } return transientFieldPaths; } //# sourceMappingURL=schemas.js.map