0.1.8Updated 24 days ago
// deno-lint-ignore-file no-explicit-any
import { MemCache } from "@utils/memcache";
import { Postgres } from "@db/pg";
import { EntityQuery, type QueryCache } from "../modules/db/entity_query.ts";
import { EventEmitter } from "../modules/db/event_emitter.ts";

export interface Entity extends DB.Entity {}
export class Entity extends EventEmitter {
  constructor(params: DB.Entity) {
    super();

    this.id = params.id;
    this.created_at = params.created_at;

    const t = this as any;

    for(const [key, value] of Object.entries(params)) {
      if(Object.hasOwn(params, key)) {
        if(this.EntityClass.is_json(key) && typeof value === 'string') {
          t[key] = JSON.parse(value);
        } else {
          t[key] = value;
        }
      }
    }

    this.__parent_class = this.EntityClass.name;

    delete t.deleted_at;
  }

  get EntityClass() {
    return this.constructor as typeof Entity;
  }

  async save() {
    this.emit('save');

    if(!this.id) {
      const [ id ] = await this.EntityClass.insert(this.__clean());
      this.id = id;
      this.created_at = new Date();

      return this;
    }

    await this.EntityClass.update({ id: this.id! }, this.__clean());

    this.emit('saved');

    return this;
  }

  __clean(keep_id?: boolean) {
    const t = {} as Record<string, unknown>;

    for(const key of Object.keys(this)) {
      if(key.startsWith('__')) continue;

      if(key == 'id' && !keep_id) continue;

      if(['created_at', 'deleted_at'].includes(key)) {
        continue;
      }

      t[key] = (this as Record<string, unknown>)[key];

      if(this.EntityClass.is_json(key)) {
        t[key] = JSON.stringify(t[key]);
      }
    }

    return t;
  }

  // region Cache

  private static __QueryCache: Record<string, MemCache<QueryCache<unknown>>> = {};
  private static __InstanceCache: Record<string, MemCache<Entity>> = {};
  private static __ArrayCache: Record<string, MemCache<Entity[]>> = {};

  static QueryCache<T extends Entity>(this: DB.Entity.Constructor<T>) {
    return (this.__QueryCache[this.name] as MemCache<QueryCache<T>>) ||= new MemCache<QueryCache<T>>();
  }

  static InstanceCache<T extends Entity>(this: DB.Entity.Constructor<T>) {
    return (this.__InstanceCache[this.name] as MemCache<T>) ||= new MemCache<T>();
  }

  static ArrayCache<T extends Entity>(this: DB.Entity.Constructor<T>) {
    return (this.__ArrayCache[this.name] as MemCache<T[]>) ||= new MemCache<T[]>();
  }

  // region Static Events

  static get table() {
    return this.name.toLowerCase();
  }

  protected get table() {
    return this.EntityClass.table;
  }

  static find<T extends Entity>(this: DB.Entity.Constructor<T>, filter: DB.Filterable<T> = {}): EntityQuery<T> {
    return new EntityQuery(this as unknown as typeof Entity, (this as unknown as typeof Entity).table, filter);
  }

  static async findOne<T extends Entity>(this: DB.Entity.Constructor<T>, filter: DB.Filterable<T> = {}): Promise<T | null> {
    return (await this.find(filter))[0];
  }

  static async count<T extends Entity>(this: DB.Entity.Constructor<T>, filter: DB.Filterable<T> = {}) {
    return await this.find(filter).count();
  }

  static async exists<T extends Entity>(this: DB.Entity.Constructor<T>, filter: DB.Filterable<T> = {}) {
    return await this.find(filter).exists();
  }

  static async delete<T extends Entity>(this: DB.Entity.Constructor<T>, filter: DB.Filterable<T> = {}, permanent?: boolean) {
    const changed_row_count = await this.find(filter).delete(permanent);
    this.emit('delete', { modified: changed_row_count });
    this.emit('rows_changed', { modified: changed_row_count });
    return changed_row_count;
  }

  static async restore<T extends Entity>(this: DB.Entity.Constructor<T>, filter: DB.Filterable<T> = {}) {
    const changed_row_count = await this.find(filter).restore();
    this.emit('restore', { modified: changed_row_count });
    this.emit('rows_changed', { modified: changed_row_count });
    return changed_row_count;
  }

  static async update<T extends Entity>(this: DB.Entity.Constructor<T>, filter: DB.Filterable<T>, values: Partial<DB.Entity.Clean<T>>) {
    const changed_row_count = await this.find(filter).update(values);
    this.emit('update', { modified: changed_row_count });
    this.emit('rows_changed', { modified: changed_row_count });
    return changed_row_count;
  }

  static async insert<T extends Entity>(this: DB.Entity.Constructor<T>, input: Omit<Partial<DB.Entity.Clean<T>>, 'id' | 'created_at'> | Omit<Partial<DB.Entity.Clean<T>>, 'id' | 'created_at'>[]): Promise<number[]> {
    const items = [input].flat() as Partial<DB.Entity.Clean<T>>[];

    if(items.length == 0) return [];

    const keys = Object.entries(items[0]).filter(([k, v]) => !['id', 'created_at'].includes(k) && v !== undefined).map(([k]) => k);
    const values: unknown[] = [];

    const valueRows = items.map((item, rowIndex) => {
      const rowValues = keys.map((key, colIndex) => {
        const value = (item as any)[key];
        if(value === undefined) return undefined;

        values.push(value);
        return `$${rowIndex * keys.length + colIndex + 1}`;
      }).filter(v => v !== undefined);

      return `(${rowValues.join(', ')})`;
    });

    const sql = `INSERT INTO "${this.table}" (${keys.join(', ')}) VALUES ${valueRows.join(', ')} RETURNING id`;

    const { rows } = await Postgres.query<{ id: number }>(sql, values);

    let i = 0;
    for(const row of rows) {
      this.emit('insert', { modified: rows.length, instance: { ...items[i++], id: row.id } as any });
    }
    this.emit('rows_changed', { modified: rows.length });

    return rows.map(row => row.id) || [];
  }

  static Relationship<T extends Entity, O extends typeof Entity>(this: DB.Entity.Constructor<T>, other: O, relationship_config: Omit<DB.Filtering.RelationshipFilter<T, InstanceType<O>>, 'via'>): DB.Filtering.RelationshipFilter<T, InstanceType<O>> {
    return {
      via: other,
      ...relationship_config
    }
  }

  // region JSON Utils

  private static __json_fields: Record<string, Set<string | number | symbol>> = {}

  static mark_json_field<T extends Entity, F extends keyof DB.Entity.Stripped<T>>(this: DB.Entity.Constructor<T>, field: F) {
    if(!this.is_json(field)) this.__json_fields[this.name].add(typeof field === 'string' ? field.toLowerCase() : field);
  }

  static is_json(field_name: string | number | symbol): boolean {
    this.__json_fields[this.name] ||= new Set();
    return this.__json_fields[this.name].has(typeof field_name === 'string' ? field_name.toLowerCase() : field_name);
  }
}