docorm
Version:
Persistence layer with ORM features for JSON documents
369 lines • 18.4 kB
JavaScript
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