UNPKG

pure-orm

Version:

A SQL Toolkit based on pure business objects passed to and from stateful data access objects

668 lines (619 loc) 21.9 kB
import camelCase from 'camelcase'; export interface IColumnData { column: string; property?: string; references?: IModelClass; primaryKey?: boolean; } export type IColumn = IColumnData | string; export type IColumns = Array<IColumn> | (() => Array<IColumn>); export interface IColumnInternalData { column: string; property: string; references?: IModelClass; primaryKey: boolean; } export type IColumnInternal = IColumnInternalData; export type IColumnsInternal = Array<IColumnInternal>; export interface IModel { [key: string]: any; } // IModel used as a type refers to an instance of IModel; // IModelClass used as a type refers to the class itself export type IModelClass = new (props: any) => IModel; export interface ICollection<T extends IModel> { models: Array<T>; } export interface IEntity<T extends IModel> { tableName: string; displayName?: string; collectionDisplayName?: string; columns: IColumns; Model: new (props: any) => T; Collection: new ({ models }: any) => ICollection<T>; } export type IEntities<T extends IModel> = Array<IEntity<T>>; export interface IEntityInternal<T extends IModel> { tableName: string; displayName: string; collectionDisplayName: string; columns: IColumnsInternal; propertyNames: Array<string>; Model: new (props: any) => T; Collection: new ({ models }: any) => ICollection<T>; columnNames: Array<string>; prefixedColumnNames: Array<string>; primaryKeys: Array<string>; references: object; selectColumnsClause: string; getPkId: (model: IModel) => string; columnToPropertyMap: Map<string, string>; propertyToColumnMap: Map<string, string>; referencesEntries: Array<{ property: string; ModelClass: IModelClass }>; } export type IEntitiesInternal<T extends IModel> = Array<IEntityInternal<T>>; export interface ICreateCoreOptions { entities: IEntities<IModel>; } export interface ICore { /* ------------------------------------------------------------------------*/ /* Object Relational Mapping methods --------------------------------------*/ /* ------------------------------------------------------------------------*/ /* Note these construction methods ensure their count against the number of * generated top level business objects - independent of the number of * relational rows passed in as a result from a database driver query. * Thus, for example, `one` understands that there may be multiple result * rows (which a database driver's `one` query method would throw at) but * which correctly nest into one Model.) */ createFromDatabase: <T extends ICollection<IModel>>(rows: any) => T; createAnyFromDatabase: <T extends ICollection<IModel>>( rows: any, rootKey: string | IModelClass ) => T; createOneFromDatabase: <T extends IModel>(rows: any) => T; createOneOrNoneFromDatabase: <T extends IModel>(rows: any) => T | void; createManyFromDatabase: <T extends ICollection<IModel>>(rows: any) => T; /* ------------------------------------------------------------------------*/ /* Helpful Properties -----------------------------------------------------*/ /* ------------------------------------------------------------------------*/ /* The tables property gives access to the sql select clause string for * each entity based on it's `displayName`. This property can be used when * writing raw SQL as the select clause, which handles quoting column names * and namespacing them to the table to avoid collisions and as required * for PureORM mapping. */ tables: { [key: string]: { columns: string } }; getEntityByModel: (model: IModel) => IEntityInternal<IModel>; getEntityByTableName: (tableName: string) => IEntityInternal<IModel>; } export const createCore = ({ entities: externalEntities }: ICreateCoreOptions): ICore => { const entities: IEntitiesInternal<IModel> = externalEntities.map( (d: IEntity<IModel>) => { const tableName = d.tableName; const displayName = d.displayName || camelCase(d.tableName); const collectionDisplayName = d.collectionDisplayName || `${displayName}s`; const columns = ( typeof d.columns === 'function' ? d.columns() : d.columns ).map((d: IColumn) => { if (typeof d === 'string') { return { column: d, property: camelCase(d), primaryKey: false }; } return { column: d.column, property: d.property || camelCase(d.column), primaryKey: d.primaryKey || false, ...(d.references ? { references: d.references } : {}) }; }); const propertyNames = columns.map( (x: IColumnInternal): string => x.property ); const columnNames = columns.map((x: IColumnInternal): string => x.column); const prefixedColumnNames = columnNames.map( (col: string) => `${tableName}#${col}` ); const Model = d.Model; const Collection = d.Collection; const pkColumnsData = columns.filter( (x: IColumnInternal) => x.primaryKey ); const _primaryKeys = pkColumnsData.map((x: IColumnInternal) => x.column); const primaryKeys = _primaryKeys.length > 0 ? _primaryKeys : ['id']; const getPkId = (model: IModel): string => { let id = ''; for (let i = 0; i < primaryKeys.length; i++) { const part = model[primaryKeys[i] as keyof typeof model]; if (part !== void 0 && part !== null) { id += String(part); } } return id; }; const references: any = {}; const referencesEntries: Array<{ property: string; ModelClass: IModelClass; }> = []; for (const col of columns) { if (col.references) { references[col.property] = col.references; referencesEntries.push({ property: col.property, ModelClass: col.references }); } } const columnToPropertyMap = new Map<string, string>(); const propertyToColumnMap = new Map<string, string>(); for (let i = 0; i < columnNames.length; i++) { columnToPropertyMap.set(columnNames[i], propertyNames[i]); propertyToColumnMap.set(propertyNames[i], columnNames[i]); } const selectColumnsClause = prefixedColumnNames .map( (prefixed: string, index: number) => `"${tableName}".${columnNames[index]} as "${prefixed}"` ) .join(', '); return { tableName, displayName, collectionDisplayName, columns, propertyNames, Model, Collection, columnNames, prefixedColumnNames, primaryKeys, references, selectColumnsClause, getPkId, columnToPropertyMap, propertyToColumnMap, referencesEntries }; } ); const tableNameToEntityMap = entities.reduce( ( map: Map<string, IEntityInternal<IModel>>, entity: IEntityInternal<IModel> ) => { map.set(entity.tableName, entity); return map; }, new Map() ); const getEntityByTableName = (tableName: string): IEntityInternal<IModel> => { const entity = tableNameToEntityMap.get(tableName); if (!entity) { throw new Error(`Could not find entity for table ${tableName}`); } return entity; }; const modelToEntityMap = entities.reduce( ( map: Map<IModel, IEntityInternal<IModel>>, entity: IEntityInternal<IModel> ) => { map.set(entity.Model, entity); return map; }, new Map() ); const getEntityByModelClass = ( Model: IModelClass ): IEntityInternal<IModel> => { const entity = modelToEntityMap.get(Model); if (!entity) { throw new Error(`Could not find entity for class ${Model}`); } return entity; }; const getEntityByModel = (model: IModel): IEntityInternal<IModel> => { return getEntityByModelClass(model.constructor as IModelClass); }; const entityReferencePlans = new Map< IEntityInternal<IModel>, Array<{ property: string; targetEntity: IEntityInternal<IModel> }> >(); for (let i = 0; i < entities.length; i++) { const entity = entities[i]; const plans = new Array<{ property: string; targetEntity: IEntityInternal<IModel>; }>(entity.referencesEntries.length); for (let j = 0; j < entity.referencesEntries.length; j++) { const ref = entity.referencesEntries[j]; plans[j] = { property: ref.property, targetEntity: getEntityByModelClass(ref.ModelClass) }; } entityReferencePlans.set(entity, plans); } interface IRowColumnPlan { rowKey: string; propertyName: string; } interface IEntityRowPlan { entity: IEntityInternal<IModel>; columnPlans: Array<IRowColumnPlan>; primaryKeyRowKeys: Array<string>; } const getPkIdFromRow = ( row: any, primaryKeyRowKeys: Array<string> ): string => { let id = ''; for (let i = 0; i < primaryKeyRowKeys.length; i++) { const part = row[primaryKeyRowKeys[i]]; if (part !== void 0 && part !== null) { id += String(part); } } return id; }; const buildEntityRowPlans = (sampleRow: any): Array<IEntityRowPlan> => { const plansByTable = new Map<string, IEntityRowPlan>(); const tableOrder: Array<string> = []; for (const text in sampleRow) { if (!Object.prototype.hasOwnProperty.call(sampleRow, text)) { continue; } const hashIndex = text.indexOf('#'); if (hashIndex === -1) { throw new Error('Column names must be namespaced to table'); } const tableName = text.substring(0, hashIndex); const column = text.substring(hashIndex + 1); let plan = plansByTable.get(tableName); if (!plan) { const entity = getEntityByTableName(tableName); const primaryKeyRowKeys = entity.primaryKeys.map( (pk: string) => `${tableName}#${pk}` ); plan = { entity, columnPlans: [], primaryKeyRowKeys }; plansByTable.set(tableName, plan); tableOrder.push(tableName); } let propertyName = plan.entity.columnToPropertyMap.get(column); if (!propertyName) { if (column.startsWith('meta_')) { propertyName = camelCase(column); } else { throw Error( `No property name for "${column}" in business object "${plan.entity.displayName}". Non-spec'd columns must begin with "meta_".` ); } } plan.columnPlans.push({ rowKey: text, propertyName }); } const orderedPlans = new Array<IEntityRowPlan>(tableOrder.length); for (let i = 0; i < tableOrder.length; i++) { orderedPlans[i] = plansByTable.get(tableOrder[i]) as IEntityRowPlan; } return orderedPlans; }; const materializeModelsFromRow = ( row: any, entityRowPlans: Array<IEntityRowPlan>, rootScopedModelsByEntity: IModelsByEntity, rowModels: Array<IModel | void>, rowModelPkIds: Array<string>, rowCreatedWithPkIndexes: Array<number> ): IModel => { rowCreatedWithPkIndexes.length = 0; for (let i = 0; i < entityRowPlans.length; i++) { const plan = entityRowPlans[i]; const pkId = getPkIdFromRow(row, plan.primaryKeyRowKeys); rowModelPkIds[i] = pkId; if (pkId) { let modelsForEntity = rootScopedModelsByEntity.get(plan.entity); if (!modelsForEntity) { modelsForEntity = new Map<string, IModel>(); rootScopedModelsByEntity.set(plan.entity, modelsForEntity); } else { const existing = modelsForEntity.get(pkId); if (existing) { rowModels[i] = existing; continue; } } } else if (i !== 0) { // No primary key means this is typically an outer-joined null row. // Skip model construction for non-root entities since it cannot link. rowModels[i] = void 0; continue; } const props: any = {}; for (let j = 0; j < plan.columnPlans.length; j++) { const columnPlan = plan.columnPlans[j]; props[columnPlan.propertyName] = row[columnPlan.rowKey]; } const model = new plan.entity.Model(props); if (pkId) { // modelsForEntity is guaranteed to be initialized above for pk rows. (rootScopedModelsByEntity.get(plan.entity) as Map<string, IModel>).set( pkId, model ); rowCreatedWithPkIndexes.push(i); } rowModels[i] = model; } return rowModels[0] as IModel; }; type IModelsByEntity = Map<IEntityInternal<IModel>, Map<string, IModel>>; interface IRootScopeState { modelsByEntity: IModelsByEntity; collectionMembership?: WeakMap< IModel, Map<IEntityInternal<IModel>, Set<string>> >; } type IRootScopeStateByKey = Map<string, IRootScopeState>; const getRootScopeKey = ( row: any, rootEntity: IEntityInternal<IModel>, rootPrimaryKeys: Array<string> ): string => { let rootScopeKey = ''; for (let i = 0; i < rootPrimaryKeys.length; i++) { if (i > 0) { rootScopeKey += '@'; } const value = row[`${rootEntity.tableName}#${rootPrimaryKeys[i]}`]; rootScopeKey += value === void 0 || value === null ? '' : String(value); } return rootScopeKey; }; const ensureRootScopeState = ( rootScopeKey: string, rootScopeStateByKey: IRootScopeStateByKey ): IRootScopeState => { let state = rootScopeStateByKey.get(rootScopeKey); if (!state) { state = { modelsByEntity: new Map<IEntityInternal<IModel>, Map<string, IModel>>() }; rootScopeStateByKey.set(rootScopeKey, state); } return state; }; const ensureCollectionMembership = ( rootScopeState: IRootScopeState ): WeakMap<IModel, Map<IEntityInternal<IModel>, Set<string>>> => { if (!rootScopeState.collectionMembership) { rootScopeState.collectionMembership = new WeakMap< IModel, Map<IEntityInternal<IModel>, Set<string>> >(); } return rootScopeState.collectionMembership; }; const linkSourceToTarget = ({ sourceEntity, sourceModel, sourceModelPkId, targetEntity, targetModel, collectionMembership }: { sourceEntity: IEntityInternal<IModel>; sourceModel: IModel; sourceModelPkId: string; targetEntity: IEntityInternal<IModel>; targetModel: IModel; collectionMembership: WeakMap< IModel, Map<IEntityInternal<IModel>, Set<string>> >; }) => { sourceModel[targetEntity.displayName as keyof typeof sourceModel] = targetModel; const collectionKey = sourceEntity.collectionDisplayName as keyof typeof targetModel; let collection = targetModel[collectionKey]; if (!collection) { const Collection = sourceEntity.Collection; const createdCollection = new Collection({ models: [] }); // Keep ORM back-reference collections out of default JSON serialization. Object.defineProperty(targetModel, collectionKey, { value: createdCollection, writable: true, configurable: true, enumerable: false }); collection = createdCollection; } let byCollection = collectionMembership.get(targetModel); if (!byCollection) { byCollection = new Map<IEntityInternal<IModel>, Set<string>>(); collectionMembership.set(targetModel, byCollection); } let memberIds = byCollection.get(sourceEntity); if (!memberIds) { memberIds = new Set<string>(); byCollection.set(sourceEntity, memberIds); } if (!memberIds.has(sourceModelPkId)) { collection.models.push(sourceModel); memberIds.add(sourceModelPkId); } }; /* * createFromDatabase architecture: * 1) Compile row plans once (column -> property mapping per entity/table). * 2) Materialize models per row with scoped de-duplication by root scope key. * 3) Index models by root scope + entity + entity primary key. * 4) Link refs incrementally as new models appear. * 5) Return root models in first-seen root scope order. */ const createFromDatabase = <T extends ICollection<IModel>>(rows: any): T => { const result = Array.isArray(rows) ? rows : [rows]; const len = result.length; const entityRowPlans = buildEntityRowPlans(result[0]); const selectedEntities = new Set<IEntityInternal<IModel>>(); for (let i = 0; i < entityRowPlans.length; i++) { selectedEntities.add(entityRowPlans[i].entity); } const applicableRefPlans = new Map< IEntityInternal<IModel>, Array<{ property: string; targetEntity: IEntityInternal<IModel> }> >(); for (let i = 0; i < entityRowPlans.length; i++) { const entity = entityRowPlans[i].entity; const refs = entityReferencePlans.get(entity) || []; const filteredRefs = refs.filter((ref) => selectedEntities.has(ref.targetEntity) ); applicableRefPlans.set(entity, filteredRefs); } const rootEntity = entityRowPlans[0].entity; const rootPrimaryKeys = rootEntity.primaryKeys; const rootScopeOrder: Array<string> = []; const rootModelsByScopeKey = new Map<string, IModel>(); const rootScopeStateByKey: IRootScopeStateByKey = new Map(); let currentRootScopeKey: string | void = void 0; let currentRootScopeState: IRootScopeState | void = void 0; const rowModels = new Array<IModel | void>(entityRowPlans.length); const rowModelPkIds = new Array<string>(entityRowPlans.length); const rowCreatedWithPkIndexes: Array<number> = []; // Phase 1: materialize and index model instances by root scope + entity. for (let i = 0; i < len; i++) { const row = result[i]; const rootScopeKey = getRootScopeKey(row, rootEntity, rootPrimaryKeys); let rootScopeState: IRootScopeState | void = currentRootScopeState; if (!rootScopeState || rootScopeKey !== currentRootScopeKey) { rootScopeState = ensureRootScopeState( rootScopeKey, rootScopeStateByKey ); currentRootScopeKey = rootScopeKey; currentRootScopeState = rootScopeState; } const rootModel = materializeModelsFromRow( row, entityRowPlans, rootScopeState.modelsByEntity, rowModels, rowModelPkIds, rowCreatedWithPkIndexes ); if (!rootModelsByScopeKey.has(rootScopeKey)) { rootScopeOrder.push(rootScopeKey); rootModelsByScopeKey.set(rootScopeKey, rootModel); } for (let c = 0; c < rowCreatedWithPkIndexes.length; c++) { const j = rowCreatedWithPkIndexes[c]; const sourceModel = rowModels[j] as IModel; const sourceModelPkId = rowModelPkIds[j]; const sourceEntity = entityRowPlans[j].entity; const refs = applicableRefPlans.get(sourceEntity); if (!refs || refs.length === 0) { continue; } for (let r = 0; r < refs.length; r++) { const ref = refs[r]; const refId = sourceModel[ref.property as keyof typeof sourceModel]; if (refId == null) { continue; } const targetPkId = String(refId); const target = rootScopeState.modelsByEntity .get(ref.targetEntity) ?.get(targetPkId); if (target) { linkSourceToTarget({ sourceEntity, sourceModel, sourceModelPkId, targetEntity: ref.targetEntity, targetModel: target, collectionMembership: ensureCollectionMembership(rootScopeState) }); } } } } const models = new Array<IModel>(rootScopeOrder.length); for (let i = 0; i < rootScopeOrder.length; i++) { models[i] = rootModelsByScopeKey.get(rootScopeOrder[i]) as IModel; } const Collection = getEntityByModel(models[0]).Collection; return <T>new Collection({ models }); }; const createAnyFromDatabase = <T extends ICollection<IModel>>( rows: any, rootKey: string | IModelClass ): T => { if (!rows || !rows.length) { const Collection = typeof rootKey === 'string' ? getEntityByTableName(rootKey).Collection : getEntityByModelClass(rootKey).Collection; return new Collection({ models: [] }) as T; } return <T>createFromDatabase<T>(rows); }; const createOneFromDatabase = <T extends IModel>(rows: any): T => { if (!rows || !rows.length) { throw Error('Did not get one.'); } const collection = createFromDatabase<ICollection<IModel>>(rows); if (!collection || !collection.models || collection.models.length === 0) { throw Error('Did not get one.'); } else if (collection.models.length > 1) { throw Error('Got more than one.'); } return <T>collection.models[0]; }; const createOneOrNoneFromDatabase = <T extends IModel>( rows: any ): T | void => { if (!rows || !rows.length) { return void 0; } return <T>createOneFromDatabase(rows); }; const createManyFromDatabase = <T extends ICollection<IModel>>( rows: any ): T => { if (!rows || !rows.length) { throw Error('Did not get at least one.'); } return <T>createFromDatabase(rows); }; return { getEntityByModel, getEntityByTableName, createFromDatabase, createAnyFromDatabase, createOneFromDatabase, createOneOrNoneFromDatabase, createManyFromDatabase, tables: entities.reduce((accum: any, data: IEntityInternal<IModel>) => { accum[data.displayName] = { columns: data.selectColumnsClause }; return accum; }, {}) }; };