UNPKG

@clickup/ent-framework

Version:

A PostgreSQL graph-database-alike library with microsharding and row-level security

160 lines (140 loc) 4.83 kB
import hash from "object-hash"; import type { CountInput, ExistsInput, InsertInput, LoadByInput, Row, SelectByInput, SelectInput, Table, UniqueKey, UpdateInput, } from "../types"; import type { Query } from "./Query"; export interface SchemaClass { new < TTable extends Table, TUniqueKey extends UniqueKey<TTable> = UniqueKey<TTable>, >( name: string, table: TTable, uniqueKey?: TUniqueKey, ): Schema<TTable, TUniqueKey>; } /** * Schema is like a "table" in some database (sharded, but it's beyond the scope * of Schema). It is also a factory of Query: it knows how to build runnable * Query objects. This 2nd role is database engine specific (e.g. there might be * PgSchema, RedisSchema etc.): such composition simplifies the code and lowers * the number of abstractions. * * The set of supported Queries is opinionated and is crafted carefully to * support the minimal possible list of primitives, but at the same time, be not * too limited in the queries the DB engine can execute. */ export abstract class Schema< TTable extends Table, TUniqueKey extends UniqueKey<TTable> = UniqueKey<TTable>, > { readonly hash: string; /** * Used in e.g. inverses. This casts this.constructor to SchemaClass with all * static methods and `new` semantic (TS doesn't do it by default; for TS, * x.constructor is Function). */ ["constructor"]!: SchemaClass; // Below is the API to be used by BaseEnt (which is engine-agnostic). /** * Generates a new ID for the row. Used when e.g. there is a beforeInsert * trigger on the Ent which needs to know the ID beforehand. */ abstract idGen(): Query<string>; /** * Creates a new row. Returns null if the row violates some unique key * constraint, otherwise returns the row ID. */ abstract insert(input: InsertInput<TTable>): Query<string | null>; /** * Upserts a row. Always returns the row ID. */ abstract upsert(input: InsertInput<TTable>): Query<string>; /** * Updates one single row by its ID. Returns true if it actually existed. */ abstract update(id: string, input: UpdateInput<TTable>): Query<boolean>; /** * Deletes a row by id. Returns true if it actually existed. */ abstract delete(id: string): Query<boolean>; /** * "Load" family of methods means that we load exactly one row. This one * returns a row by its ID or null if it's not found. */ abstract load(id: string): Query<Row<TTable> | null>; /** * Loads one single row by its unique key ("by" denotes that it's based on an * unique key, not on an ID). Returns null if it's not found. */ abstract loadBy( input: LoadByInput<TTable, TUniqueKey>, ): Query<Row<TTable> | null>; /** * "Select" family of methods means that we load multiple rows ("by" denotes * that it's based on an unique key, not on an arbitrary query). This one * returns all rows whose unique key prefix matches the input. */ abstract selectBy( input: SelectByInput<TTable, TUniqueKey>, ): Query<Array<Row<TTable>>>; /** * Returns all rows matching an arbitrary query. */ abstract select(input: SelectInput<TTable>): Query<Array<Row<TTable>>>; /** * Returns the number of rows matching an arbitrary query. */ abstract count(input: CountInput<TTable>): Query<number>; /** * An optimized version of count() for the cases where we only need to know * whether at least one row exists, and don't need a precise count. */ abstract exists(input: ExistsInput<TTable>): Query<boolean>; constructor( /** For relational databases, it's likely a table name. */ public readonly name: string, /** Structure of the table. */ public readonly table: TTable, /** Fields which the native unique key consists of (if any). */ public readonly uniqueKey: TUniqueKey, ) { if (!Object.keys(this.table).length) { throw Error("Must have at least one field"); } for (const field of this.uniqueKey) { if (this.table[field].autoUpdate) { throw Error( "All fields in upsert unique key list must be non-auto-updatable", ); } } // For perf reasons, simplicity and easier CAS support, we don't // identifier-escape field names in query builder code, so here, we enforce // the field names to be simple and not require any escaping. (Notice that // we DO escape table names though.) for (const field of Object.keys(this.table)) { if (!field.match(/^[_a-z][_a-z0-9]*$/)) { throw Error( `Field name must be a simple identifier, but '${field}' passed`, ); } } this.hash = this.name + ":" + hash([this.table, this.uniqueKey], { algorithm: "md5", ignoreUnknown: true, }); } }