uui-db/src/DbTable.ts (196 lines of code) (raw):

import { DbTablePatch, DbQuery, DbEntitySchema, DbPkFieldType, DbTablesSet, } from './types'; import * as I from 'immutable'; import { SortDirection, getFilterPredicate, getOrderComparer, DataQueryFilter, SortingOption, getSearchFilter, DataQuery, } from '@epam/uui-core'; import { Seq } from 'immutable'; interface DbIndex<TEntity> { field: keyof TEntity; map: I.Map<any, I.Set<any>>; } interface DbTableState<TEntity, TTables extends DbTablesSet<TTables>> { pk: I.Map<any, TEntity>; indexes: DbIndex<TEntity>[]; } export class DbTable<TEntity, TId extends DbPkFieldType, TTables extends DbTablesSet<TTables>> { constructor(public readonly schema: DbEntitySchema<TEntity, TId, TTables>, private state?: DbTableState<TEntity, TTables>, private q?: DbQuery<TEntity>) { if (!state) { const indexes: DbIndex<TEntity>[] = (schema.indexes || []).map((indexDef) => { return { field: indexDef, map: I.Map(), }; }); this.state = { pk: I.Map(), indexes }; } if (!q) { this.q = {}; } } protected keyToImmutable(id: TId): any { if (Array.isArray(id)) { return I.List(id); } else { return id; } } public byId(id: TId): TEntity { return this.state.pk.get(this.keyToImmutable(id)); } public getId(entity: Partial<TEntity>): TId { if (Array.isArray(this.schema.primaryKey)) { return this.schema.primaryKey.map((key: keyof TEntity) => entity[key]) as any as TId; } return entity[this.schema.primaryKey as keyof TEntity] as any as TId; } private update(mutate: (t: DbTable<TEntity, TId, TTables>) => void): this { const clone = new (this.constructor as any)(this.schema, this.state, this.q); mutate(clone); return clone; } /* Mutation */ public with(patch: DbTablePatch<TEntity>) { const updates = patch.map((entityPatch) => { const id = this.getId(entityPatch); const immId = this.keyToImmutable(id); const existing = this.state.pk.get(immId); let isDeleted = false; let updated = entityPatch as TEntity; if (existing) { updated = { ...existing, ...entityPatch }; if (this.schema.deleteFlag) { isDeleted = (updated[this.schema.deleteFlag] as unknown) === true; } } return { id, immId, patch: entityPatch, existing, updated, isNew: !existing, isDeleted, }; }); const idVal = Seq.Keyed<TId, TEntity>(updates.map((entity) => [entity.immId, entity.updated])); let newPk = this.state.pk.merge(idVal); // TBD: replace with deleteAll after migrating to immutable 4 updates .filter((u) => u.isDeleted) .forEach((u) => { newPk = newPk.delete(u.immId); }); const newIndexes = this.state.indexes.map((index) => { let newMap = index.map; for (let n = 0; n < updates.length; n++) { const update = updates[n]; const newKey = update.updated[index.field]; if (update.existing) { const oldKey = update.existing[index.field]; if (oldKey !== newKey || update.isDeleted) { newMap = newMap.update(oldKey, (set) => set.remove(update.immId)); } } if (!update.isDeleted) { newMap = newMap.update(newKey, I.Set(), (set) => set.add(update.immId)); } } return { ...index, map: newMap }; }); const newState: DbTableState<TEntity, TTables> = { pk: newPk, indexes: newIndexes }; return this.update((t) => (t.state = newState)); } /* Query */ public find(filter: DataQueryFilter<TEntity>): DbTable<TEntity, TId, TTables> { return this.update((t) => (t.q = { ...t.q, filter: { ...(this.q.filter as any), ...(filter as any) } })); } public order(order: SortingOption[]) { return this.update((t) => (t.q = { ...t.q, sorting: order })); } public orderBy(field: Extract<keyof TEntity, string>, direction: SortDirection = 'asc') { return this.update((t) => (t.q = { ...t.q, sorting: [{ field, direction }] })); } public thenBy(field: Extract<keyof TEntity, string>, direction?: SortDirection) { return this.update((t) => (t.q = { ...t.q, sorting: [...t.q.sorting, { field, direction }] })); } public search(text: string) { if (!this.schema.searchBy) { throw new Error(`Can't search in the ${this.schema.typeName} table - searchBy is not defined in the schema.`); } return this.update((t) => (t.q = { ...t.q, search: text })); } /* Materializing */ public range(from: number, count: number): TEntity[] { return this.runQuery({ ...this.q, range: { from, count } }); } public count() { return this.runQuery(this.q).length; } public one(): TEntity { return this.runQuery(this.q)[0] || null; } public toArray(): TEntity[] { return this.runQuery(this.q); } public map<T>(fn: (item: TEntity, index?: number) => T) { return this.toArray().map(fn); } /* Query implementation */ private runQuery(q: DataQuery<TEntity>) { let result: TEntity[] = null; let filter = q.filter; if (filter) { const indexes = this.state.indexes; // Try to use indexes to fulfill filter conditions for (let n = 0; n < indexes.length; n++) { const index = indexes[n]; if (index.field in filter) { const { [index.field]: condition, ...rest } = filter as any; let conditionValues: any[] = null; if (condition != null && typeof condition === 'object') { // Attempt to use index for 'in' and 'isNull' criteria. // We need to be very conservative here, indexed field should work the same way as getPatternPredicate. So // - it's better to leave corner cases to getPatternPredicate const { in: inCriteria, isNull: nullCriteria, ...restCriteria } = condition as any; if (inCriteria && Array.isArray(inCriteria)) { conditionValues = inCriteria; } else { restCriteria.in = inCriteria; } // Conditions in getPatternPredicate are composed with 'and' logic, // For now, let's not attempt to handle tricky cases like { in: [1,2] isNull: true }, or { in: [1, null] isNull: false } // - just leave this to getPatternPredicate. // Let's just handle the a most common case { isNull: true } - find all null and undefined if (nullCriteria === true && !conditionValues) { conditionValues = [null, undefined]; } else { restCriteria.in = inCriteria; } // Keep other conditions, e.g. { in: [1,2,3], isNull: true } - in works via index, isNull - via filter if (Object.keys(restCriteria).length > 0) { rest[index.field] = restCriteria; } } else { conditionValues = [condition]; } if (conditionValues) { result = []; for (let i = 0; i < conditionValues.length; i++) { const idsSet = index.map.get(conditionValues[i]); if (idsSet) { const idsArray = idsSet.toArray(); for (let j = 0; j < idsArray.length; j++) { const item = this.state.pk.get(idsArray[j]); result.push(item); } } } filter = Object.keys(rest).length > 0 ? (rest as any) : null; break; } } } } if (!result) { result = this.state.pk.toArray(); } if (filter) { const predicate = getFilterPredicate<TEntity>(q.filter); result = result.filter(predicate); } if (q.search) { const searchFilter = getSearchFilter(q.search); result = result.filter((item) => searchFilter(this.schema.searchBy.map((field) => (item as any)[field]))); } if (q.sorting) { const comparer = getOrderComparer(q.sorting); result = result.sort(comparer); } if (q.range) { result = result.slice(q.range.from, q.range.from + q.range.count); } return result; } }