Source

persistence/Adapter.ts

import {
  BaseError,
  DBKeys,
  InternalError,
  NotFoundError,
  Context,
  OperationKeys,
  RepositoryFlags,
  Contextual,
  DefaultRepositoryFlags,
} from "@decaf-ts/db-decorators";
import { Observer } from "../interfaces/Observer";
import {
  Constructor,
  Decoration,
  DefaultFlavour,
  Model,
  ModelConstructor,
  ModelRegistry,
} from "@decaf-ts/decorator-validation";
import { SequenceOptions } from "../interfaces/SequenceOptions";
import { RawExecutor } from "../interfaces/RawExecutor";
import { Observable } from "../interfaces/Observable";
import { PersistenceKeys } from "./constants";
import { Query } from "../query/Query";
import { Statement } from "../query/Statement";
import { ClauseFactory } from "../query/ClauseFactory";
import { Condition } from "../query/Condition";
import { Repository } from "../repository/Repository";
import { Sequence } from "./Sequence";
import { ErrorParser } from "../interfaces";

Decoration.setFlavourResolver((obj: object) => {
  try {
    return (
      Adapter.flavourOf(Model.isModel(obj) ? obj.constructor : (obj as any)) ||
      DefaultFlavour
    );
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
  } catch (e: unknown) {
    return DefaultFlavour;
  }
});

/**
 * @summary Abstract Decaf-ts Persistence Adapter Class
 * @description Offers the base implementation for all Adapter Classes
 * and manages them various registered {@link Adapter}s
 *
 * @typedef Y the underlying persistence object type or the required config to set it up
 * @typedef Q The query object the adapter uses
 *
 * @param {Y} native the underlying persistence object
 * @param {string} flavour the under witch the persistence adapter should be stored
 *
 * @class Adapter
 * @implements RawExecutor
 * @implements Observable
 */
export abstract class Adapter<
    Y,
    Q,
    F extends RepositoryFlags,
    C extends Context<F>,
  >
  implements RawExecutor<Q>, Observable, Contextual<F>, ErrorParser
{
  private static _current: Adapter<any, any, any, any>;
  private static _cache: Record<string, Adapter<any, any, any, any>> = {};

  protected readonly _observers: Observer[] = [];
  private readonly _native: Y;

  get native() {
    return this._native;
  }

  repository<M extends Model>(): Constructor<
    Repository<M, Q, Adapter<Y, Q, F, C>, F, C>
  > {
    return Repository;
  }

  protected constructor(
    native: Y,
    readonly flavour: string
  ) {
    if (this.flavour in Adapter._cache)
      throw new InternalError(
        `Persistence adapter flavour ${this.flavour} already registered`
      );
    this._native = native;
    Adapter._cache[this.flavour] = this;
    if (!Adapter._current) Adapter._current = this;
  }

  Query<M extends Model>(): Query<Q, M> {
    return new Query(this);
  }

  abstract parseCondition(condition: Condition): Q;

  abstract get Statement(): Statement<Q>;

  abstract get Clauses(): ClauseFactory<Y, Q, typeof this>;

  protected isReserved(attr: string) {
    return !attr;
  }

  abstract parseError(err: Error): BaseError;

  abstract initialize(...args: any[]): Promise<void>;

  abstract Sequence(options: SequenceOptions): Promise<Sequence>;

  async context<
    M extends Model,
    C extends Context<F>,
    F extends RepositoryFlags,
  >(
    operation:
      | OperationKeys.CREATE
      | OperationKeys.READ
      | OperationKeys.UPDATE
      | OperationKeys.DELETE,
    overrides: Partial<F>,
    model: Constructor<M>
  ): Promise<C> {
    const AdapterContext = class extends Context<F> {
      constructor(obj: F) {
        super(obj);
      }
    };
    return new AdapterContext(
      Object.assign({}, DefaultRepositoryFlags, overrides, {
        affectedTables: Repository.table(model),
        writeOperation: operation !== OperationKeys.READ,
        timestamp: new Date(),
        operation: operation,
      }) as F
    ) as C;
  }

  prepare<M extends Model>(
    model: M,
    pk: keyof M
  ): {
    record: Record<string, any>;
    id: string;
  } {
    const result = Object.entries(model).reduce(
      (accum: Record<string, any>, [key, val]) => {
        // if (key === pk) return accum;
        const mappedProp = Repository.column(model, key);
        if (this.isReserved(mappedProp))
          throw new InternalError(`Property name ${mappedProp} is reserved`);
        accum[mappedProp] = val;
        return accum;
      },
      {}
    );
    if ((model as any)[PersistenceKeys.METADATA])
      Object.defineProperty(result, PersistenceKeys.METADATA, {
        enumerable: false,
        writable: false,
        configurable: true,
        value: (model as any)[PersistenceKeys.METADATA],
      });
    return {
      record: result,
      id: model[pk] as string,
    };
  }

  revert<M extends Model>(
    obj: Record<string, any>,
    clazz: string | Constructor<M>,
    pk: keyof M,
    id: string | number | bigint
  ): M {
    const ob: Record<string, any> = {};
    ob[pk as string] = id;
    const m = (
      typeof clazz === "string" ? Model.build(ob, clazz) : new clazz(ob)
    ) as M;
    const metadata = obj[PersistenceKeys.METADATA];
    const result = Object.keys(m).reduce((accum: M, key) => {
      if (key === pk) return accum;
      (accum as Record<string, any>)[key] = obj[Repository.column(accum, key)];
      return accum;
    }, m);
    if (metadata)
      Object.defineProperty(result, PersistenceKeys.METADATA, {
        enumerable: false,
        configurable: false,
        writable: false,
        value: metadata,
      });
    return result;
  }

  abstract create(
    tableName: string,
    id: string | number,
    model: Record<string, any>,
    ...args: any[]
  ): Promise<Record<string, any>>;

  async createAll(
    tableName: string,
    id: (string | number)[],
    model: Record<string, any>[],
    ...args: any[]
  ): Promise<Record<string, any>[]> {
    if (id.length !== model.length)
      throw new InternalError("Ids and models must have the same length");
    return Promise.all(
      id.map((i, count) => this.create(tableName, i, model[count], ...args))
    );
  }

  abstract read(
    tableName: string,
    id: string | number | bigint,
    ...args: any[]
  ): Promise<Record<string, any>>;

  async readAll(
    tableName: string,
    id: (string | number | bigint)[],
    ...args: any[]
  ): Promise<Record<string, any>[]> {
    return Promise.all(id.map((i) => this.read(tableName, i, ...args)));
  }

  abstract update(
    tableName: string,
    id: string | number,
    model: Record<string, any>,
    ...args: any[]
  ): Promise<Record<string, any>>;

  async updateAll(
    tableName: string,
    id: string[] | number[],
    model: Record<string, any>[],
    ...args: any[]
  ): Promise<Record<string, any>[]> {
    if (id.length !== model.length)
      throw new InternalError("Ids and models must have the same length");
    return Promise.all(
      id.map((i, count) => this.update(tableName, i, model[count], ...args))
    );
  }

  abstract delete(
    tableName: string,
    id: string | number | bigint,
    ...args: any[]
  ): Promise<Record<string, any>>;

  async deleteAll(
    tableName: string,
    id: (string | number | bigint)[],
    ...args: any[]
  ): Promise<Record<string, any>[]> {
    return Promise.all(id.map((i) => this.delete(tableName, i, ...args)));
  }

  abstract raw<R>(rawInput: Q, process: boolean, ...args: any[]): Promise<R>;

  /**
   * @summary Registers an {@link Observer}
   * @param {Observer} observer
   *
   * @see {Observable#observe}
   */
  observe(observer: Observer): void {
    const index = this._observers.indexOf(observer);
    if (index !== -1) throw new InternalError("Observer already registered");
    this._observers.push(observer);
  }

  /**
   * @summary Unregisters an {@link Observer}
   * @param {Observer} observer
   *
   * @see {Observable#unObserve}
   */
  unObserve(observer: Observer): void {
    const index = this._observers.indexOf(observer);
    if (index === -1) throw new InternalError("Failed to find Observer");
    this._observers.splice(index, 1);
  }

  /**
   * @summary calls all registered {@link Observer}s to update themselves
   * @param {any[]} [args] optional arguments to be passed to the {@link Observer#refresh} method
   */
  async updateObservers(...args: any[]): Promise<void> {
    const results = await Promise.allSettled(
      this._observers.map((o) => o.refresh(...args))
    );
    results.forEach((result, i) => {
      if (result.status === "rejected")
        console.warn(
          `Failed to update observable ${this._observers[i]}: ${result.reason}`
        );
    });
  }

  toString() {
    return `${this.flavour} persistence Adapter`;
  }

  static flavourOf<M extends Model>(model: Constructor<M>): string {
    return (
      Reflect.getMetadata(this.key(PersistenceKeys.ADAPTER), model) ||
      this.current.flavour
    );
  }

  static get current() {
    if (!Adapter._current)
      throw new InternalError(
        `No persistence flavour set. Please initialize your adapter`
      );
    return Adapter._current;
  }

  static get<Y, Q, C extends Context<F>, F extends RepositoryFlags>(
    flavour: any
  ): Adapter<Y, Q, F, C> | undefined {
    if (flavour in this._cache) return this._cache[flavour];
    throw new InternalError(`No Adapter registered under ${flavour}.`);
  }

  static setCurrent(flavour: string) {
    const adapter = Adapter.get(flavour);
    if (!adapter)
      throw new NotFoundError(`No persistence flavour ${flavour} registered`);
    this._current = adapter;
  }

  static key(key: string) {
    return Repository.key(key);
  }

  static models<M extends Model>(flavour: string) {
    try {
      const registry = (Model as any).getRegistry() as ModelRegistry<any>;
      const cache = (
        registry as unknown as { cache: Record<string, ModelConstructor<any>> }
      ).cache;
      const managedModels: ModelConstructor<any>[] = Object.values(cache)
        .map((m: ModelConstructor<M>) => {
          let f = Reflect.getMetadata(
            Adapter.key(PersistenceKeys.ADAPTER),
            m as ModelConstructor<any>
          );
          if (f && f === flavour) return m;
          if (!f) {
            const repo = Reflect.getMetadata(
              Repository.key(DBKeys.REPOSITORY),
              m as ModelConstructor<any>
            );
            if (!repo) return;
            const repository = Repository.forModel(m);

            f = Reflect.getMetadata(
              Adapter.key(PersistenceKeys.ADAPTER),
              repository
            );
            return f;
          }
        })
        .filter((m) => !!m);
      return managedModels;
    } catch (e: any) {
      throw new InternalError(e);
    }
  }
}