UNPKG

@travetto/model-sql

Version:

SQL backing for the travetto model module, with real-time modeling support for SQL schemas.

333 lines (295 loc) 11.5 kB
import { castKey, castTo, type Class, TypedObject } from '@travetto/runtime'; import type { SelectClause, SortClause } from '@travetto/model-query'; import { ModelRegistryIndex, type ModelType, type OptionalId } from '@travetto/model'; import { type SchemaClassConfig, type SchemaFieldConfig, DataUtil, SchemaRegistryIndex } from '@travetto/schema'; import type { DialectState, InsertWrapper, VisitHandler, VisitState, VisitInstanceNode, OrderBy } from './internal/types.ts'; import { TableSymbol, type VisitStack } from './types.ts'; type FieldCacheEntry = { local: SchemaFieldConfig[]; localMap: Record<string, SchemaFieldConfig>; foreign: SchemaFieldConfig[]; foreignMap: Record<string, SchemaFieldConfig>; }; /** * Utilities for dealing with SQL operations */ export class SQLModelUtil { static #schemaFieldsCache = new Map<Class, FieldCacheEntry>(); /** * Creates a new visitation stack with the class as the root */ static classToStack(type: Class): VisitStack[] { return [{ type, name: type.name }]; } /** * Clean results from db, by dropping internal fields */ static cleanResults<T, U = T>(state: DialectState, item: T[]): U[]; static cleanResults<T, U = T>(state: DialectState, item: T): U; static cleanResults<T, U = T>(state: DialectState, item: T | T[]): U | U[] { if (Array.isArray(item)) { return item.filter(value => value !== null && value !== undefined).map(value => this.cleanResults(state, value)); } else if (!DataUtil.isSimpleValue(item)) { for (const key of TypedObject.keys(item)) { if (item[key] === null || item[key] === undefined || key === state.parentPathField.name || key === state.pathField.name || key === state.idxField.name) { delete item[key]; } else { item[key] = this.cleanResults(state, item[key]); } } return castTo({ ...item }); } else { return castTo(item); } } /** * Get all available fields at current stack path */ static getFieldsByLocation(stack: VisitStack[]): FieldCacheEntry { const top = stack.at(-1)!; const config = SchemaRegistryIndex.getOptional(top.type)?.get(); if (config && this.#schemaFieldsCache.has(config.class)) { return this.#schemaFieldsCache.get(config.class)!; } if (!config) { // If a simple type, it is it's own field const field: SchemaFieldConfig = castTo({ ...top }); return { local: [field], localMap: { [field.name]: field }, foreign: [], foreignMap: {} }; } const hasModel = ModelRegistryIndex.has(config.class)!; const fields = Object.values(config.fields).map(field => ({ ...field })); // Polymorphic if (hasModel && config.discriminatedBase) { const fieldMap = new Set(fields.map(field => field.name)); for (const type of SchemaRegistryIndex.getDiscriminatedClasses(config.class)) { const typeConfig = SchemaRegistryIndex.getConfig(type); for (const [fieldName, field] of Object.entries<SchemaFieldConfig>(typeConfig.fields)) { if (!fieldMap.has(fieldName)) { fieldMap.add(fieldName); fields.push({ ...field, required: { active: false } }); } } } } const entry: FieldCacheEntry = { localMap: {}, foreignMap: {}, local: fields.filter(field => !SchemaRegistryIndex.has(field.type) && !field.array), foreign: fields.filter(field => SchemaRegistryIndex.has(field.type) || field.array) }; entry.local.reduce((map, field) => (map[field.name] = field) && map, entry.localMap); entry.foreign.reduce((map, field) => (map[field.name] = field) && map, entry.foreignMap); this.#schemaFieldsCache.set(config.class, entry); return entry; } /** * Process a schema structure, synchronously */ static visitSchemaSync(config: SchemaClassConfig | SchemaFieldConfig, handler: VisitHandler<void>, state: VisitState = { path: [] }): void { const path = 'fields' in config ? this.classToStack(config.class) : [...state.path, config]; const { local: fields, foreign } = this.getFieldsByLocation(path); const descend = (): void => { for (const field of foreign) { if (SchemaRegistryIndex.has(field.type)) { this.visitSchemaSync(field, handler, { path }); } else { handler.onSimple({ config: field, fields: [], path: [...path, field] }); } } }; if ('fields' in config) { return handler.onRoot({ config, fields, descend, path }); } else { return handler.onSub({ config, fields, descend, path }); } } /** * Visit a Schema structure */ static async visitSchema(config: SchemaClassConfig | SchemaFieldConfig, handler: VisitHandler<Promise<void>>, state: VisitState = { path: [] }): Promise<void> { const path = 'fields' in config ? this.classToStack(config.class) : [...state.path, config]; const { local: fields, foreign } = this.getFieldsByLocation(path); const descend = async (): Promise<void> => { for (const field of foreign) { if (SchemaRegistryIndex.has(field.type)) { await this.visitSchema(field, handler, { path }); } else { await handler.onSimple({ config: field, fields: [], path: [...path, field] }); } } }; if ('fields' in config) { return handler.onRoot({ config, fields, descend, path }); } else { return handler.onSub({ config, fields, descend, path }); } } /** * Process a schema instance by visiting it synchronously. This is synchronous to prevent concurrent calls from breaking */ static visitSchemaInstance<T extends ModelType>(cls: Class<T>, instance: T | OptionalId<T>, handler: VisitHandler<unknown, VisitInstanceNode<unknown>>): void { const pathStack: unknown[] = [instance]; this.visitSchemaSync(SchemaRegistryIndex.getConfig(cls), { onRoot: (config) => { const { path } = config; path[0].name = instance.id!; handler.onRoot({ ...config, value: instance }); return config.descend(); }, onSub: (config) => { const { config: field } = config; const topObject: Record<string, unknown> = castTo(pathStack.at(-1)); const top = config.path.at(-1)!; if (field.name in topObject) { const valuesInput = topObject[field.name]; const values = Array.isArray(valuesInput) ? valuesInput : [valuesInput]; let i = 0; for (const value of values) { try { pathStack.push(value); config.path[config.path.length - 1] = { ...top, index: i++ }; handler.onSub({ ...config, value }); if (!field.array) { config.descend(); } } finally { pathStack.pop(); } i += 1; } if (field.array) { config.descend(); } } }, onSimple: (config) => { const { config: field } = config; const topObject: Record<string, unknown> = castTo(pathStack.at(-1)); const value = topObject[field.name]; return handler.onSimple({ ...config, value }); } }); } /** * Get list of selected fields */ static select<T>(cls: Class<T>, select?: SelectClause<T>): SchemaFieldConfig[] { if (!select || Object.keys(select).length === 0) { return [{ type: cls, name: '*', class: cls, array: false }]; } const { localMap } = this.getFieldsByLocation(this.classToStack(cls)); let toGet = new Set<string>(); for (const [key, value] of TypedObject.entries(select)) { if (typeof key === 'string' && !DataUtil.isPlainObject(select[key]) && localMap[key]) { if (!value) { if (toGet.size === 0) { toGet = new Set(Object.keys(SchemaRegistryIndex.getConfig(cls).fields)); } toGet.delete(key); } else { toGet.add(key); } } } return [...toGet].map(field => localMap[field]); } /** * Get list of Order By clauses */ static orderBy<T>(cls: Class<T>, sort: SortClause<T>[]): OrderBy[] { return sort.map((cl: Record<string, unknown>) => { let schema: SchemaClassConfig = SchemaRegistryIndex.getConfig(cls); const stack = this.classToStack(cls); let found: OrderBy | undefined; while (!found) { const key = Object.keys(cl)[0]; const value = cl[key]; const field = { ...schema.fields[key] }; if (DataUtil.isPrimitive(value)) { stack.push(field); found = { stack, asc: value === 1 }; } else { stack.push(field); schema = SchemaRegistryIndex.getConfig(field.type); cl = castTo(value); } } return found; }); } /** * Find all dependent fields via child tables */ static collectDependents<T>(state: DialectState, parent: unknown, items: T[], field?: SchemaFieldConfig): Record<string, T> { if (field) { const isSimple = SchemaRegistryIndex.has(field.type); for (const item of items) { const parentKey: string = castTo(item[castKey<T>(state.parentPathField.name)]); const root = castTo<Record<string, Record<string, unknown>>>(parent)[parentKey]; const fieldKey = castKey<(typeof root) | T>(field.name); if (field.array) { if (!root[fieldKey]) { root[fieldKey] = [isSimple ? item : item[fieldKey]]; } else if (Array.isArray(root[fieldKey])) { root[fieldKey].push(isSimple ? item : item[fieldKey]); } } else { root[fieldKey] = isSimple ? item : item[fieldKey]; } } } const mapping: Record<string, T> = {}; for (const item of items) { const key = item[castKey<T>(state.pathField.name)]; if (typeof key === 'string') { mapping[key] = item; } } return mapping; } /** * Build table name via stack path */ static buildTable(list: VisitStack[]): string { const top = list.at(-1)!; if (!top[TableSymbol]) { top[TableSymbol] = list.map((item, i) => i === 0 ? ModelRegistryIndex.getStoreName(item.type) : item.name).join('_'); } return top[TableSymbol]!; } /** * Build property path for a table/field given the current stack */ static buildPath(list: VisitStack[]): string { return list.map((item) => `${item.name}${item.index ? `[${item.index}]` : ''}`).join('.'); } /** * Get insert statements for a given class, and its child tables */ static async getInserts<T extends ModelType>(cls: Class<T>, items: (T | OptionalId<T>)[]): Promise<InsertWrapper[]> { const wrappers: Record<string, InsertWrapper> = {}; const track = (stack: VisitStack[], value: unknown): void => { const key = this.buildTable(stack); (wrappers[key] ??= { stack, records: [] }).records.push({ stack, value }); }; const all = items.map(item => this.visitSchemaInstance(cls, item, { onRoot: ({ path, value }) => track(path, value), onSub: ({ path, value }) => track(path, value), onSimple: ({ path, value }) => track(path, value) })); await Promise.all(all); const result = [...Object.values(wrappers)].toSorted((a, b) => a.stack.length - b.stack.length); return result; } }