import { CancelToken } from "@web-atoms/core/dist/core/types.js"; import DateTime from "@web-atoms/date-time/dist/DateTime.js"; import { Cloner } from "../models/Cloner.js"; import IClrEntity, { IClrEntityLike } from "../models/IClrEntity.js"; import IEntityModel, { EntityContext } from "../models/IEntityModel.js"; import mergeProperties from "./mergeProperties.js"; import Query, { IDateRange, IEntityWithDateRange, stepTypes } from "./Query.js"; import resolve from "./resolve.js"; import { QueryProcessor } from "./QueryProcessor.js"; import TaskManager from "../models/TaskManager.js"; import FetchBuilder from "@web-atoms/core/dist/services/FetchBuilder.js"; (Symbol as any).asyncDispose ??= Symbol("asyncDispose"); (Symbol as any).dispose ??= Symbol("dispose"); export interface IGeometry { latitude: number; longitude: number; wktString?: string; difference?(g: IGeometry): IGeometry; intersection?(g: IGeometry): IGeometry; union?(g: IGeometry): IGeometry; symmetricDifference?(g: IGeometry): IGeometry; distance?(g: IGeometry): number; isWithinDistance?(g: IGeometry, distance: number): boolean; touches?(g: IGeometry): boolean; intersects?(g: IGeometry): boolean; crosses?(g: IGeometry): boolean; within?(g: IGeometry): boolean; contains?(g: IGeometry): boolean; overlaps?(g: IGeometry): boolean; covers?(g: IGeometry): boolean; coveredBy?(g: IGeometry): boolean; } export interface IKeyCollection extends ICollection { key?: TKey; } export interface ICollection extends Array { sum?(filter?: (item: T) => number): number; min?(filter?: (item: T) => number): number; max?(filter?: (item: T) => number): number; average?(filter?: (item: T) => number): number; groupBy?(this: ICollection, selector: (item: T) => TK): ICollection>; where?(filter: (item: T) => boolean): ICollection; any?(filter?: (item: T) => boolean): boolean; select?(select: (item: T) => TR): ICollection; selectMany?(select: (item: T) => TR[]): ICollection; firstOrDefault?(filter?: (item: T) => boolean): T; count?(filter?: (item: T) => boolean): number; toArray?(): ICollection; toList?(): ICollection; take?(n: number): ICollection; orderBy?(item: (item: T) => any): ICollection; thenBy?(item: (item: T) => any): ICollection; orderByDescending?(item: (item: T) => any): ICollection; thenByDescending?(item: (item: T) => any): ICollection; } const ArrayPrototype = Array.prototype as any; const orderBy = function (f) { return [].concat(this).sort((a, b) => { const ak = f(a); const bk = f(b); if (typeof ak === "string") { return ak.toLowerCase().localeCompare((bk as string).toLowerCase()); } return ak - bk; }); }; const orderByDescending = function (f) { return [].concat(this).sort((a, b) => { const ak = f(a); const bk = f(b); if (typeof ak === "string") { return bk.toLowerCase().localeCompare((ak as string).toLowerCase()); } return bk - ak; }); }; Object.defineProperties(ArrayPrototype, { where: { enumerable: false, value: ArrayPrototype.filter, configurable: true }, any: { enumerable: false, value: ArrayPrototype.some, configurable: true }, select: { enumerable: false, value: ArrayPrototype.map, configurable: true }, selectMany: { enumerable: false, value(x) { const r = []; for (const iterator of this) { const items = x(iterator); if (Array.isArray(items)) { r.push(... items); } } return r; }, configurable: true }, firstOrDefault: { enumerable: false, value(f) { if (f) { return ArrayPrototype.find.apply(this, arguments); } return this[0]; }, configurable: true }, sum: { enumerable: false, value(f) { let n = 0; for (const iterator of this) { n += f(iterator) ?? 0; } return n; }, configurable: true }, average: { enumerable: false, value(f) { if (this.length === 0) { return 0; } let n = 0; for (const iterator of this) { n += f(iterator) ?? 0; } return n / this.length; }, configurable: true }, orderBy: { enumerable: false, value: orderBy, configurable: true }, thenBy: { enumerable: false, value: orderBy, configurable: false }, orderByDescending: { enumerable: false, value: orderByDescending, configurable: true }, thenByDescending: { enumerable: false, value: orderByDescending, configurable: false }, count: { enumerable: false, value(f) { if (!f) { return this.length; } let length = 0; for (const iterator of this) { if (f(iterator)) { length++; } } return length; }, configurable: false } }); export interface IMethod { select?: [string, ... any[]]; where?: [string, ... any[]]; orderBy?: [string, ... any[]]; orderByDescending?: [string, ... any[]]; thenBy?: [string, ... any[]]; thenByDescending?: [string, ... any[]]; } export interface IMethodsFilter { methods: IMethod[]; start: number; size: number; } export interface IModifications { [key: string]: any; } export interface IBulkUpdateModel { keys: IClrEntity[]; update: IModifications; throwWhenNotFound?: boolean; } export interface IBulkDeleteModel { keys: IClrEntity[]; throwWhenNotFound?: boolean; } export type IQueryMethod = ["select", string, ... any[]] | ["where", string, ... any[]] | ["joinDateRange", string, ... any[]] | ["orderBy", string, ... any[]] | ["orderByDescending", string, ... any[]] | ["thenBy", string, ... any[]] | ["thenByDescending", string, ... any[]] | ["include", string] | ["thenInclude", string] | ["dateRange", string, ... any[]]; export interface IListParams { cancelToken?: CancelToken; /** * Query will resolve references by replacing $id attributed objects */ doNotResolve?: boolean; /** * Do not display activity indicator */ hideActivityIndicator?: boolean; /** * Response will include cache-control with given seconds as max age */ cacheSeconds?: number; /** * Arbitrary cache version to invalidate previous version */ cacheVersion?: string; /** * True if cacheSeconds is greater than zero, set false to turn it off */ cacheImmutable?: boolean; /** * Split server side includes */ splitInclude?: boolean; } export interface IPagedListParams extends IListParams { start?: number; size?: number; more?: boolean; count?: boolean; } export interface IColumn { name?: string; type?: string; length?: number; dataType?: string; generated?: string; default?: any; } export interface IRelation { name?: string; fkMap?: { fk: string, relatedKey: string }[]; relatedName?: string; isCollection?: boolean; isInverse?: boolean; relatedModel?: IModel; } export interface IModelSchema { name: string; keys: IColumn[]; properties: IColumn[]; relations: IRelation[]; queries?: { [key: string]: any }; actions?: { [key: string]: any }; } export interface IModel { name: string; create?(properties?: IClrEntityLike): T; patch?(original: IClrEntityLike, updates: IClrEntityLike): T; schema?: IModelSchema; } export class DefaultFactory { constructor(public readonly factory: () => any) {} } export class Model implements IModel { private defaults: [string, any][]; constructor( public name: string, public readonly keys: string[] = [], defaults: any = null, public schema = null as IModelSchema ) { if (defaults) { this.defaults = []; for (const key in defaults) { if (Object.prototype.hasOwnProperty.call(defaults, key)) { const element = defaults[key]; this.defaults.push([key, element]); } } } } public create(properties: IClrEntityLike = {} as any): T { (properties as any).$type = this.name; if (this.defaults) { for (const [key, value] of this.defaults) { if (properties[key] === void 0) { if (value instanceof DefaultFactory) { properties[key] = value.factory(); } else { properties[key] = value; } } } } return properties as T; } public patch(original: IClrEntityLike, updates: IClrEntityLike) { for (const iterator of this.keys) { const originalKey = original[iterator]; const updatedKey = updates[iterator]; if (updatedKey && updatedKey !== originalKey) { throw new Error(`Cannot update ${iterator} as it is the primary key`) } updates[iterator] = originalKey; } return { $type: this.name, ... updates } as T; } } export type IPrimitive = string | null | number | boolean; export type ArrayItem = T extends Array ? T : never; export default abstract class BaseEntityService extends TaskManager { public url: string = "/api/entity/"; public abstract queryProcessor: QueryProcessor; protected resultConverter = resolve; private entityModel: EntityContext; public cloner(item: T): Cloner { return new Cloner(item); } public async model(): Promise { if (this.entityModel) { return this.entityModel; } using busy = this.createBusyIndicator(false); const c = await FetchBuilder.get(`${this.url}model`) .asJson(); this.entityModel = new EntityContext(c); return this.entityModel; } public dateRange(start: DateTime, end: DateTime, step: stepTypes ): Query { return new Query({ service: this, name: "NeuroSpeech.EntityAccessControl.DateRange", traceQuery: false}, [ ["dateRange", "@0,@1,@2", start, end, step] ]); } query(m: IModel, queryFunction?: keyof TR, ... args: IPrimitive[]): Query { return new Query({ service: this, name: m.name, queryProcessor: this.queryProcessor, queryFunction: queryFunction as any, args }); } queryNavigation(entity: T, navigation: PR): Query> queryNavigation(entity: T, navigation: PR): Query queryNavigation(entity: any, navigation: any): any { const name = entity.$type; const { $key } = entity; return new Query({ service: this, name, entityKey: $key, navigation }); } queryEntity(m: IModel, entity: T, queryFunction?: keyof TR, ... args: IPrimitive[]): Query { let entityKey; const { $key } = entity; if (!$key) { throw new Error(`Entity does not contain public/private key`); } entityKey = $key; return new Query({ service: this, name: m.name, queryProcessor: this.queryProcessor, queryFunction: queryFunction as any, entityKey, args }); } as() { return this as any as Query; } async delete(body: T): Promise { using _busy = this.createBusyIndicator(false); const url = this.url; // return this.deleteJson({url, body}); return await FetchBuilder.delete(url).jsonBody(body).asJson(); } async insert(body: IClrEntity): Promise { using _busy = this.createBusyIndicator(false); const url = this.url; // return this.putJson({url, body}); const result = await FetchBuilder.put(url).jsonBody(body).asJson(); return this.resultConverter(result); } async invoke(m: IModel, method: keyof TA, argEntity: T, ... args: any[]) { using _busy = this.createBusyIndicator(false); // will send keys only... // const m = (await this.model()).for(argEntity.$type); const { $type, $key: key } = argEntity; let keys = void 0; if (!key) { keys = { }; for(const key of m.schema.keys) { keys[key.name] = argEntity[key.name]; } } const result = await FetchBuilder.post(`${this.url}invoke/${$type}/${method as any}`) .withFetchProxy((r, i) => this.queueRun(() => fetch(r, i))) .jsonBody({ key, keys, args }) .asJson(); return this.resultConverter(result); // return this.postJson({ // url: `${this.url}invoke/${entity.$type}/${method as any}`, // method: "POST", // body: { // entity, // args // } // }) as Promise; } buildRunUrl(m: IModel, method: keyof TA, argEntity: T, { args = void 0 as any[], cacheSeconds = 0, cacheVersion = void 0 as any } = { }) { const { $type, $key } = argEntity; if (!$key) { throw new Error(`Run requires encrypted $key`); } const usp = new URLSearchParams(); usp.append("key", $key); if (args) { usp.append("args", JSON.stringify(args)); } if (cacheSeconds) { usp.append("cache", cacheSeconds.toString()); } if (cacheVersion) { usp.append("cv", cacheVersion); } return `${this.url}run/${$type}/${method as any}?${usp.toString()}`; } /** * This method will execute external function for the entity that has $key included. * @param m model * @param method external method name * @param argEntity entity * @param param3 * @returns */ run(m: IModel, method: keyof TA, argEntity: T,{ args = void 0 as any[], cacheSeconds = 0, cacheVersion = void 0 as any } = { }) { const url = this.buildRunUrl(m, method, argEntity, { args, cacheSeconds, cacheVersion }); return FetchBuilder.get(url) .withFetchProxy((r, i) => this.queueRun(() => fetch(r, i))) .jsonPostProcessor(this.resultConverter); } /** * This method will execute external function for the entity that does not have $key included. * This will cause read filter to be executed before the actual function execution. * @param m model * @param method method name * @param argEntity entity * @param args arguments * @returns */ async runFiltered(m: IModel, method: keyof TA, argEntity: T, ... args: any[]) { // const context = await this.model(); using _busy = this.createBusyIndicator(false); // will send keys only... const keys = { }; // const m = context.for(argEntity.$type); for(const key of m.schema.keys) { keys[key.name] = argEntity[key.name]; } return FetchBuilder.post(`${this.url}run/${m.name}/${method as any}`) .withFetchProxy((r, i) => this.queueRun(() => fetch(r, i))) .jsonBody({ keys, args }) .jsonPostProcessor(this.resultConverter); } public save(body: T, cloner?: (c: Cloner) => Cloner, trace?: boolean): Promise; public save(body: T[], cloner?: (c: Cloner) => Cloner, trace?: boolean): Promise; public async save(body: any, cloner?: (c: Cloner) => Cloner, trace?: boolean): Promise { if (Array.isArray(body) && body.length === 0) { return body; } using _busy = this.createBusyIndicator(false); let url = this.url; if (body instanceof Cloner) { body = body.copy; } if (trace) { const hasQuery = url.includes("?"); if (hasQuery) { url = url += "&trace=true"; } else { url = url += "?trace=true"; } } if (cloner) { if (Array.isArray(body)) { body = body.map((x) => cloner(new Cloner(x)).copy); } else { const c = cloner(new Cloner(body)); body = c.copy; } } // const result = await this.postJson({ // url, body // }); let result = await FetchBuilder.post(url) .jsonBody(body) .asJson(); result = this.resultConverter(result); mergeProperties(result, body); return body; } public async update(e: T, update: IModifications): Promise { await this.bulkUpdate([e], update); for (const key in update) { if (Object.prototype.hasOwnProperty.call(update, key)) { const element = update[key]; e[key] = element; } } return e; } public async bulkUpdate( entities: T[], update: IModifications, throwWhenNotFound: boolean = false): Promise { const model = await this.model(); using busy = this.createBusyIndicator(false); const keys = []; for (const iterator of entities) { const entityType = model.for(iterator.$type); const key = { $type: iterator.$type }; for (const { name } of entityType.keys) { key[name] = iterator[name]; } keys.push(key); } const body = { keys, update, throwWhenNotFound }; const url = `${this.url}bulk`; let results = await FetchBuilder.post(url) .jsonBody(body) .asJson(); results = this.resultConverter(results); return results; } public async bulkDelete( entities: T[], throwWhenNotFound: boolean = false): Promise { const model = await this.model(); using busy = this.createBusyIndicator(false); const keys = []; for (const iterator of entities) { const entityType = model.for(iterator.$type); const key = { $type: iterator.$type }; for (const { name } of entityType.keys) { key[name] = iterator[name]; } keys.push(key); } const url = `${this.url}bulk`; const body = { keys, throwWhenNotFound }; // await this.deleteJson({ // url, // body // }); let results = await FetchBuilder.delete(url) .jsonBody(body) .asJson(); results = this.resultConverter(results); return results; } // protected async fetchResponse(options: IHttpRequest): Promise { // if (!this.createBusyIndicator || options?.hideActivityIndicator) { // return await super.fetchResponse(options); // } // const disposable = this.createBusyIndicator(options); // try { // return await super.fetchResponse(options); // } finally { // disposable?.dispose(); // } // } protected createBusyIndicator(hideActivityIndicator = false) { return { [Symbol.dispose]() {}}; } } // @ts-expect-error delete BaseEntityService.prototype.createBusyIndicator;