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
text/typescript
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;
}, {})
};
};