0.1.8Updated 24 days ago
import type { Entity } from "../../classes/entity.ts";
import { Postgres, type QueryObjectResult } from "@db/pg";
import { TranslateMongoishToPostgreSQL } from "./mpg_translator.ts";

export type QueryCache<T> = QueryObjectResult<T> | { rows: [], rowCount: 0 };

export class EntityQuery<T extends Entity> implements PromiseLike<T[]> {
  constructor(
    private entity: typeof Entity,
    private table: string,
    private _filter: DB.Filterable<T>
  ) {}

  private _limit?: number;
  private _offset?: number;
  private _sort?: Partial<Record<keyof T, 'asc' | 'desc'>>;
  private _with_deleted?: boolean;
  // deno-lint-ignore no-explicit-any
  private _lookups: DB.Lookup<any, any, any, any>[] = [];

  sort(fields: Partial<Record<keyof T, 'asc' | 'desc'>>) {
    this._sort = fields;
    return this;
  }

  limit(n: number) {
    this._limit = n;
    return this;
  }

  skip(n: number) {
    this._offset = n;
    return this;
  }

  withDeleted(flag: boolean = true) {
    this._with_deleted = flag;
    return this;
  }

  lookup<Target extends DB.Entity.typeofClass, Field extends string, IsSingular extends boolean = false>(entity: Target, as: Field, config: DB.Lookup.Config<T, Target, Field, IsSingular>): DB.Lookup.ReturnType<T, Target, Field, IsSingular>
  lookup<Target extends DB.Entity.typeofClass, Field extends string, IsSingular extends boolean = false>(entity: Target, as: Field, isSingular: IsSingular, config: DB.Lookup.Config<T, Target, Field, IsSingular>): DB.Lookup.ReturnType<T, Target, Field, IsSingular>
  lookup<Target extends DB.Entity.typeofClass, Field extends string, IsSingular extends boolean = false>(entity: Target, as: Field, config_or_singular: DB.Lookup.Config<T, Target, Field, IsSingular> | boolean, config?: DB.Lookup.Config<T, Target, Field, IsSingular>): DB.Lookup.ReturnType<T, Target, Field, IsSingular> {
    let isArray: boolean;
    if(typeof config_or_singular !== 'boolean') {
      config = config_or_singular;
      isArray = true;
    } else {
      isArray = !config_or_singular;
    }

    this._lookups.push({
      entity,
      as,
      localField: config!.localField,
      foreignField: config!.foreignField,
      filter: config!.filter,
      required: config!.required,
      isArray
    });

    return this as unknown as DB.Lookup.ReturnType<T, Target, Field, IsSingular>;
  }

  private get filter() {
    if(this._with_deleted) return this._filter;
    return { $and: [ this._filter, { deleted_at: null }] } as DB.Filterable<T>;
  }

  async then<TResult1 = T[], TResult2 = never>(
    onfulfilled?: ((value: T[]) => TResult1 | PromiseLike<TResult1>) | undefined,
    onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | undefined
  ): Promise<TResult1 | TResult2> {
    const [sql, args] = this.toFindQuery();

    try {
      const key = this.cache_key;

      const { rows } = (await this.entity.QueryCache().passthrough(key, Postgres.query<T>(sql, args)))!;

      const entity_rows = rows.map(r => {
        // deno-lint-ignore no-explicit-any
        const e = new this.entity(r as Entity) as any;

        for(const lookup of this._lookups) {
          if(!e[lookup.as]) continue;

          if(lookup.isArray) {
            e[lookup.as] = (e[lookup.as] as unknown[]).map(l => new lookup.entity(l));
          } else {
            e[lookup.as] = new lookup.entity(e[lookup.as]);
          }
        }
        return e;
      }) as T[];
  
      return Promise.resolve(entity_rows).then(onfulfilled, onrejected);
    } catch(e) {
      return Promise.reject(e).catch(onrejected);
    }
  }

  async update(values: Partial<DB.Entity.Clean<T>>): Promise<number> {
    const setClauses: string[] = [];
    const args: unknown[] = [];
    let paramIndex = 1;

    for (const [key, value] of Object.entries(values)) {
      setClauses.push(`${key} = $${paramIndex++}`);
      args.push(value);
    }

    const { clause: whereClause, args: whereArgs } = TranslateMongoishToPostgreSQL(this.filter, paramIndex);
    args.push(...whereArgs);

    const sql = `UPDATE "${this.table}" SET ${setClauses.join(', ')} ${whereClause}`;
    const result = await Postgres.query(sql, args);

    return result.rowCount ?? 0;
  }

  async delete(permanent?: boolean): Promise<number> {
    const { clause, args } = TranslateMongoishToPostgreSQL(this.filter);

    let sql!: string;
    if(permanent) {
      sql = `DELETE FROM "${this.table}" ${clause}`;
    } else {
      sql = `UPDATE "${this.table}" SET deleted_at = NOW() ${clause}`;
    }

    const result = await Postgres.query(sql, args);
    return result.rowCount ?? 0;
  }

  async restore(): Promise<number> {
    const { clause, args } = TranslateMongoishToPostgreSQL(this.filter);
    const sql = `UPDATE "${this.table}" SET deleted_at = NULL ${clause}`;
    const result = await Postgres.query(sql, args);
    return result.rowCount ?? 0;
  }

  async count(): Promise<number> {
    const { clause, args } = TranslateMongoishToPostgreSQL(this.filter);
    const sql = `SELECT COUNT(*)::int as count FROM "${this.table}" ${clause}`;
    const { rows: [{ count }] } = await Postgres.query<{count: number}>(sql, args);
    return Number(count ?? 0);
  }

  async ids<F extends NumericalProperties<T>>(field: F = 'id' as F): Promise<number[]> {
    const { clause, args } = TranslateMongoishToPostgreSQL(this.filter);

    const sql = `SELECT ${field.toString()} FROM "${this.table}" ${clause}`;

    const { rows } = await Postgres.query<Record<F, number>>(sql, args);

    return rows.map(r => r[field.toString() as F]!);
  }

  async exists(): Promise<boolean> {
    const{ clause, args } = TranslateMongoishToPostgreSQL(this.filter);
    const sql = `SELECT 1 FROM "${this.table}" ${clause} LIMIT 1`;
    const { rows } = await Postgres.query<{count: number}>(sql, args);
    return rows.length > 0;
  }

  /**
   * Clears query cache for the provided query
   */
  bust(deep?: boolean) {
    const { cache_key } = this;

    if(!deep) {
      return !!this.entity.QueryCache().delete(cache_key);
    }

    const deep_key = cache_key.replace(/\s+WHERE.+$/, '');
    const entries_to_delete: string[] = [];
    for(const key of this.entity.QueryCache().keys()) {
      if(key.startsWith(deep_key)) entries_to_delete.push(key);
    }

    for(const entry of entries_to_delete) {
      this.entity.QueryCache().delete(entry);
    }
  }

  get cache_key() {
    const [sql, args] = this.toFindQuery();

    return (sql.trim() + '::' + JSON.stringify(args).trim());
  }

  private toFindQuery() {
    const { clause, args } = TranslateMongoishToPostgreSQL(this.filter);

    const orderClause = this._sort
      ? 'ORDER BY ' + Object.entries(this._sort).map(([k, v]) => `${k} ${(v as string).toUpperCase()}`).join(', ')
      : '';
    const limitClause = this._limit != null ? `LIMIT ${this._limit}` : '';
    const offsetClause = this._offset != null ? `OFFSET ${this._offset}` : '';

    const select_fields: string[] = ['*'];

    let paramIndex = args.length + 1;

    for(const lookup of this._lookups) {
      const alias = lookup.as;
      const subFilter = TranslateMongoishToPostgreSQL(lookup.filter || {}, paramIndex);

      paramIndex += subFilter.args.length;

      const clause = subFilter.clause ? `WHERE ${subFilter.clause} AND` : `WHERE`;

      const subQuery = lookup.isArray === false
        ? `(SELECT to_jsonb(${alias}.*) FROM "${lookup.entity.table}" as lkp_${alias} ${clause} "lkp_${alias}.${lookup.foreignField.toString()}" = "${lookup.localField.toString()}" LIMIT 1)`
        : `(SELECT json_agg(${alias}.*) FROM "${lookup.entity.table}" as lkp_${alias} ${clause} "lkp_${alias}.${lookup.foreignField.toString()}" = "${lookup.localField.toString()}")`;

      select_fields.push(`${subQuery} as ${alias}`);

      args.push(...subFilter.args);
    }

    const sql = `SELECT ${select_fields.join(', ')} FROM "${this.table}" ${clause} ${orderClause} ${limitClause} ${offsetClause}`;

    return [sql, args] as const;
  }
}

type NumericalProperties<T> = {[K in keyof T]: T[K] extends number ? K : never}[keyof T];