From 56260f9c12c3a3d536acfe64ea0f3520a03c610a Mon Sep 17 00:00:00 2001 From: Chiri Vulpes <6081834+ChiriVulpes@users.noreply.github.com> Date: Tue, 5 Mar 2024 03:17:22 +1300 Subject: [PATCH] Add initial insert & update queries --- src/IStrongPG.ts | 20 ++++++++- src/Schema.ts | 4 +- src/Table.ts | 72 ++++++++++++++++++++++++++++-- src/expressions/Expression.ts | 47 ++++++++++--------- src/statements/Insert.ts | 66 +++++++++++++++++++++++++++ src/statements/Select.ts | 10 ++--- src/statements/Update.ts | 34 ++++++++++++++ src/statements/table/AlterTable.ts | 14 +++--- 8 files changed, 228 insertions(+), 39 deletions(-) create mode 100644 src/statements/Insert.ts create mode 100644 src/statements/Update.ts diff --git a/src/IStrongPG.ts b/src/IStrongPG.ts index 8873cde..2631428 100644 --- a/src/IStrongPG.ts +++ b/src/IStrongPG.ts @@ -1,3 +1,5 @@ +import { ExpressionOr } from "./expressions/Expression"; + export type Type = DataTypeID; export enum DataTypeID { @@ -139,7 +141,7 @@ export type DataTypeFromString = export type ValidDate = Date | number | typeof Keyword.CurrentTimestamp; -export interface TypeMap { +export interface MigrationTypeMap { // numeric [DataTypeID.SMALLINT]: number; [DataTypeID.INTEGER]: number; @@ -171,13 +173,27 @@ export interface TypeMap { // special [DataTypeID.TSVECTOR]: null; + [DataTypeID.JSON]: null; +} + +export interface InputTypeMap extends Omit { [DataTypeID.JSON]: any; } +export interface OutputTypeMap extends Omit { + // datetime + [DataTypeID.DATE]: Date; + [DataTypeID.TIMESTAMP]: Date; + [DataTypeID.TIME]: Date; + // INTERVAL, +} + export type ValidType = string | boolean | number | symbol | Date | RegExp | undefined | null; export const SYMBOL_COLUMNS = Symbol("COLUMNS"); -export type TypeFromString = STR extends "*" ? typeof SYMBOL_COLUMNS : TypeMap[DataTypeFromString]; +export type MigrationTypeFromString = STR extends "*" ? typeof SYMBOL_COLUMNS : MigrationTypeMap[DataTypeFromString]; +export type InputTypeFromString = STR extends "*" ? typeof SYMBOL_COLUMNS : ExpressionOr]>; +export type OutputTypeFromString = STR extends "*" ? typeof SYMBOL_COLUMNS : OutputTypeMap[DataTypeFromString]; export namespace TypeString { export function resolve (typeString: TypeString) { diff --git a/src/Schema.ts b/src/Schema.ts index 6c37731..a0020e5 100644 --- a/src/Schema.ts +++ b/src/Schema.ts @@ -1,4 +1,4 @@ -import { DataTypeID, EnumToTuple, TypeString, TypeStringMap } from "./IStrongPG"; +import { DataTypeID, EnumToTuple, InputTypeFromString, OutputTypeFromString, TypeString, TypeStringMap } from "./IStrongPG"; interface SpecialKeys { PRIMARY_KEY?: keyof SCHEMA | (keyof SCHEMA)[]; @@ -153,6 +153,8 @@ namespace Schema { export type ColumnTyped = keyof { [COLUMN in keyof SCHEMA as COLUMN extends keyof SpecialKeys ? never : SCHEMA[COLUMN] extends Vaguify ? COLUMN : never]: SCHEMA[COLUMN] }; export type Columns = { [COLUMN in keyof SCHEMA as COLUMN extends keyof SpecialKeys ? never : COLUMN]: SCHEMA[COLUMN] }; + export type RowOutput = { [COLUMN in keyof SCHEMA as COLUMN extends keyof SpecialKeys ? never : COLUMN]: OutputTypeFromString> }; + export type RowInput = { [COLUMN in keyof SCHEMA as COLUMN extends keyof SpecialKeys ? never : COLUMN]: InputTypeFromString> }; type Vaguify = T extends TypeStringMap[DataTypeID.BIGINT] ? TypeStringMap[DataTypeID.BIGINT] | TypeStringMap[DataTypeID.BIGSERIAL] : T; diff --git a/src/Table.ts b/src/Table.ts index 4b57823..8596be3 100644 --- a/src/Table.ts +++ b/src/Table.ts @@ -1,17 +1,81 @@ import { Initialiser } from "./IStrongPG"; import Schema, { TableSchema } from "./Schema"; +import InsertIntoTable, { InsertIntoTableFactory } from "./statements/Insert"; import SelectFromTable from "./statements/Select"; +import UpdateTable from "./statements/Update"; export default class Table { public constructor (protected readonly name: string, protected readonly schema: SCHEMA) { } + /** + * SELECT * + */ + public select (): SelectFromTable; + /** + * SELECT * + * ...then provide an initialiser for tweaking the query + */ + public select = SelectFromTable> (initialiser: Initialiser, RETURN>): RETURN; + /** + * SELECT columns + */ public select[]> (...columns: COLUMNS): SelectFromTable; - public select[]> (...columnsAndInitialiser: [...COLUMNS, Initialiser>]): SelectFromTable; - public select (...params: (Schema.Column | Initialiser>)[]) { + /** + * SELECT columns + * ...then provide an initialiser for tweaking the query + */ + public select[], RETURN extends SelectFromTable> (...columnsAndInitialiser: [...COLUMNS, Initialiser, RETURN>]): RETURN; + public select (...params: (Schema.Column | "*" | Initialiser> | Initialiser>)[]): SelectFromTable[]> | SelectFromTable { const initialiser = typeof params[params.length - 1] === "function" ? params.pop() as Initialiser> : undefined; const query = new SelectFromTable(this.name, this.schema, params as Schema.Column[]); - initialiser?.(query); - return query; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return initialiser?.(query) ?? query; + } + + public insert (data: Partial>): InsertIntoTable; + public insert (data: Partial>, initialiser: Initialiser>): InsertIntoTable; + public insert[]> (...columns: COLUMNS): InsertIntoTableFactory; + public insert[], RETURN extends InsertIntoTableFactory | InsertIntoTable> (...columnsAndInitialiser: [...COLUMNS, Initialiser, RETURN>]): RETURN; + public insert (...params: (boolean | Partial> | Schema.Column | Initialiser> | Initialiser>)[]): InsertIntoTableFactory | InsertIntoTable { + const isUpsert = params[0] === true; + if (typeof params[0] === "boolean") + params.shift(); + + const initialiser = typeof params[params.length - 1] === "function" ? params.pop() as Initialiser | InsertIntoTable> : undefined; + + if (typeof params[0] === "object") { + const keys = Object.keys(params[0]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const query = ((this.insert as any)(isUpsert as any, ...keys as Schema.Column[]) as InsertIntoTableFactory) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return + .values(...keys.map(key => (params[0] as any)[key])); + + return initialiser?.(query as InsertIntoTable) as InsertIntoTable ?? query; + } + + const query = InsertIntoTable.columns(this.name, this.schema, params as Schema.Column[], isUpsert); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return initialiser?.(query) ?? query; + } + + public upsert (data: Schema.RowInput): InsertIntoTable; + public upsert> (data: Schema.RowInput, initialiser: Initialiser, RETURN>): RETURN; + public upsert[]> (...columns: COLUMNS): InsertIntoTableFactory; + public upsert[], RETURN extends InsertIntoTableFactory | InsertIntoTable> (...columnsAndInitialiser: [...COLUMNS, Initialiser, RETURN>]): RETURN; + public upsert (...params: (Schema.RowInput | Schema.Column | Initialiser> | Initialiser>)[]): InsertIntoTableFactory | InsertIntoTable { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call + return (this.insert as any)(true, ...params as Schema.Column[]); + } + + public update (data: Schema.RowInput): UpdateTable; + public update> (data: Schema.RowInput, initialiser: Initialiser, RETURN>): RETURN; + public update (data: Schema.RowInput, initialiser?: Initialiser, UpdateTable>): UpdateTable { + const query = new UpdateTable(this.name, this.schema); + for (const key of Object.keys(data)) + query.set(key as Schema.Column, data[key]); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument + return initialiser?.(query) ?? query; } } diff --git a/src/expressions/Expression.ts b/src/expressions/Expression.ts index 464e9c5..70ab15e 100644 --- a/src/expressions/Expression.ts +++ b/src/expressions/Expression.ts @@ -1,4 +1,4 @@ -import { Initialiser, TypeFromString, TypeString, ValidType } from "../IStrongPG"; +import { Initialiser, MigrationTypeFromString, TypeString, ValidType } from "../IStrongPG"; import Statement from "../statements/Statement"; export interface ExpressionOperations { @@ -8,7 +8,7 @@ export interface ExpressionOperations { equals: ExpressionValue; or: ExpressionValue; matches: CURRENT_VALUE extends string ? ExpressionValue : never; - as (type: TYPE): ExpressionOperations>; + as (type: TYPE): ExpressionOperations>; } export interface ExpressionValue { @@ -18,7 +18,7 @@ export interface ExpressionValue { value: ExpressionValue; - var (name: VAR): ExpressionOperations>; + var (name: VAR): ExpressionOperations>; lowercase: ExpressionValue; uppercase: ExpressionValue; nextValue (sequenceId: string): ExpressionOperations; @@ -27,14 +27,33 @@ export interface ExpressionValues { export type ExpressionInitialiser = Initialiser, ExpressionOperations>; +export type ExpressionOr = T | ExpressionInitialiser; + export type ImplementableExpression = { [KEY in keyof ExpressionValues | keyof ExpressionOperations]: any }; export default class Expression implements ImplementableExpression { + public static stringifyValue (value: ExpressionOr, vars?: any[], enableStringConcatenation = false) { + let result: string; + if (typeof value === "function") { + const expr = new Expression(vars, enableStringConcatenation); + value(expr as any as ExpressionValues); + result = `(${expr.compile()})`; + } else if (typeof value === "string" && !enableStringConcatenation) { + vars ??= []; + vars.push(value); + result = `$${vars.length}`; + } else { + result = Expression.stringifyValueRaw(value); + } + + return result; + } + /** * Warning: Do not use outside of migrations */ - public static stringifyValue (value: ValidType) { + public static stringifyValueRaw (value: ValidType) { switch (typeof value) { case "string": return `'${value}'`; @@ -61,8 +80,8 @@ export default class Expression implements ImplementableExpression } } - public static compile (initialiser: ExpressionInitialiser, enableStringConcatenation = false) { - const expr = new Expression(undefined, enableStringConcatenation); + public static compile (initialiser: ExpressionInitialiser, enableStringConcatenation = false, vars?: any[]) { + const expr = new Expression(vars, enableStringConcatenation); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument initialiser(expr as any); return new Statement.Queryable(expr.compile(), undefined, expr.vars); @@ -121,20 +140,8 @@ export default class Expression implements ImplementableExpression public value (value: ValidType | Initialiser, mapper?: (value: string) => string) { this.parts.push(() => { - let result: string; - if (typeof value === "function") { - const expr = new Expression(this.vars, this.enableStringConcatenation); - value(expr); - result = `(${expr.compile()})`; - } else if (typeof value === "string" && !this.enableStringConcatenation) { - this.vars ??= []; - this.vars.push(value); - result = `$${this.vars.length}`; - } else { - result = Expression.stringifyValue(value); - } - - return mapper ? mapper(result) : result; + const stringified = Expression.stringifyValue(value as ValidType | ExpressionInitialiser, this.vars, this.enableStringConcatenation); + return mapper ? mapper(stringified) : stringified; }); return this; diff --git a/src/statements/Insert.ts b/src/statements/Insert.ts new file mode 100644 index 0000000..141db38 --- /dev/null +++ b/src/statements/Insert.ts @@ -0,0 +1,66 @@ +import { QueryResult } from "pg"; +import { Initialiser, InputTypeFromString, Value } from "../IStrongPG"; +import Schema, { TableSchema } from "../Schema"; +import Expression from "../expressions/Expression"; +import Statement from "./Statement"; +import UpdateTable from "./Update"; + +export interface InsertIntoTableFactory[] = Schema.Column[]> { + values (...values: { [I in keyof COLUMNS]: InputTypeFromString }): InsertIntoTable; +} + +export default class InsertIntoTable extends Statement { + + public static columns[] = Schema.Column[]> (tableName: string, schema: SCHEMA, columns: COLUMNS, isUpsert = false): InsertIntoTableFactory { + return { + values: (...values: any[]) => { + const query = new InsertIntoTable(tableName, schema, columns, values as never); + if (isUpsert) { + query.onConflictDoUpdate(update => { + for (let i = 0; i < columns.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + update.set(columns[i], values[i]); + } + }); + } + + return query; + }, + }; + } + + private vars: any[] = []; + public constructor (public readonly tableName: string, public readonly schema: SCHEMA, public readonly columns: Schema.Column[], public readonly values: Value>[]) { + super(); + } + + private onConflict?: null | UpdateTable; + public onConflictDoNothing () { + this.onConflict = null; + return this; + } + + public onConflictDoUpdate (initialiser: Initialiser>) { + this.onConflict = new UpdateTable(undefined, this.schema, this.vars); + initialiser(this.onConflict); + return this; + } + + public compile () { + const values = this.values.map(value => Expression.stringifyValue(value, this.vars)).join(","); + let onConflict = this.onConflict === undefined ? " " + : this.onConflict === null ? "ON CONFLICT DO NOTHING" + : undefined; + + if (this.onConflict) { + const compiled = this.onConflict.compile()[0]; + onConflict = `ON CONFLICT DO ${compiled.text}`; + } + + return this.queryable(`INSERT INTO ${this.tableName} (${this.columns.join(",")}) VALUES (${values}) ${onConflict!}`, undefined, this.vars); + } + + protected override resolveQueryOutput (output: QueryResult) { + return output.rows as RESULT; + } +} \ No newline at end of file diff --git a/src/statements/Select.ts b/src/statements/Select.ts index 4da9115..4d0b7aa 100644 --- a/src/statements/Select.ts +++ b/src/statements/Select.ts @@ -1,12 +1,12 @@ import { QueryResult } from "pg"; -import { TypeFromString } from "../IStrongPG"; +import { MigrationTypeFromString, OutputTypeFromString } from "../IStrongPG"; import Schema, { TableSchema } from "../Schema"; import Expression, { ExpressionInitialiser } from "../expressions/Expression"; import Statement from "./Statement"; type SingleStringUnion = ((k: ((T extends any ? () => T : never) extends infer U ? ((U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never) extends () => (infer R) ? R : never : never)) => any) extends (k: T) => any ? T : never; -export default class SelectFromTable[] = Schema.Column[]> extends Statement<{ [K in COLUMNS[number]]: TypeFromString }[]> { +export default class SelectFromTable | "*")[] = Schema.Column[], RESULT = { [K in "*" extends COLUMNS[number] ? Schema.Column : COLUMNS[number]]: OutputTypeFromString }[]> extends Statement { private vars?: any[]; public constructor (public readonly tableName: string, public readonly schema: SCHEMA, public readonly columns: COLUMNS) { @@ -22,12 +22,12 @@ export default class SelectFromTable[number]>]>) { + public primaryKeyed (id: MigrationTypeFromString[number]>]>) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access const primaryKey = this.schema["PRIMARY_KEY"][0]; this.isPrimaryKeyed = true; - this.where(expr => expr.var(primaryKey).equals(id)); - return this as Statement<{ [K in COLUMNS[number]]: TypeFromString } | undefined>; + this.where(expr => expr.var(primaryKey).equals(id as never)); + return this as SelectFromTable : COLUMNS[number]]: OutputTypeFromString } | undefined>; } public compile () { diff --git a/src/statements/Update.ts b/src/statements/Update.ts new file mode 100644 index 0000000..ef07f64 --- /dev/null +++ b/src/statements/Update.ts @@ -0,0 +1,34 @@ +import { QueryResult } from "pg"; +import { InputTypeFromString, ValidType } from "../IStrongPG"; +import Schema, { TableSchema } from "../Schema"; +import Expression from "../expressions/Expression"; +import Statement from "./Statement"; + +export default class UpdateTable extends Statement { + + private vars?: any[]; + public constructor (public readonly tableName: string | undefined, public readonly schema: SCHEMA, vars?: any[]) { + super(); + this.vars = vars ?? []; + } + + private assignments: string[] = []; + public set (input: Partial>): this; + public set> (column: COLUMN_NAME, value: InputTypeFromString): this; + public set (input: Schema.Column | Partial>, value?: ValidType) { + if (typeof input === "object") + for (const column of Object.keys(input)) + this.assignments.push(`${column} = ${Expression.stringifyValue(input[column], this.vars)}`); + else + this.assignments.push(`${String(input)} = ${Expression.stringifyValue(value, this.vars)}`); + return this; + } + + public compile () { + return this.queryable(`UPDATE ${this.tableName ?? ""} SET ${this.assignments.join(", ")}`, undefined, this.vars); + } + + protected override resolveQueryOutput (output: QueryResult) { + return output.rows as RESULT; + } +} \ No newline at end of file diff --git a/src/statements/table/AlterTable.ts b/src/statements/table/AlterTable.ts index d3ce7f5..64c8776 100644 --- a/src/statements/table/AlterTable.ts +++ b/src/statements/table/AlterTable.ts @@ -1,5 +1,5 @@ import Expression, { ExpressionInitialiser } from "../../expressions/Expression"; -import { Initialiser, TypeFromString, TypeString } from "../../IStrongPG"; +import { Initialiser, MigrationTypeFromString, TypeString } from "../../IStrongPG"; import Schema, { DatabaseSchema } from "../../Schema"; import Statement from "../Statement"; @@ -129,7 +129,7 @@ class AlterTableSubStatement extends Statement { // } export class CreateColumn extends Statement.Super { - public default (value: TypeFromString | ExpressionInitialiser<{}, TypeFromString>) { + public default (value: MigrationTypeFromString | ExpressionInitialiser<{}, MigrationTypeFromString>) { return this.addStandaloneOperation(CreateColumnSubStatement.setDefault(value)); } @@ -151,9 +151,9 @@ class CreateColumnSubStatement extends Statement { /** * Warning: Do not use this outside of migrations */ - public static setDefault (value: TypeFromString | ExpressionInitialiser<{}, TypeFromString>) { + public static setDefault (value: MigrationTypeFromString | ExpressionInitialiser<{}, MigrationTypeFromString>) { const expr = typeof value === "function" ? Expression.compile(value) : undefined; - const stringifiedValue = expr?.text ?? Expression.stringifyValue(value as TypeFromString); + const stringifiedValue = expr?.text ?? Expression.stringifyValueRaw(value as MigrationTypeFromString); return new CreateColumnSubStatement(`DEFAULT (${stringifiedValue})`, expr?.values); } @@ -187,7 +187,7 @@ export class AlterColumn extends S // return this; // } - public default (value: TypeFromString | ExpressionInitialiser<{}, TypeFromString>) { + public default (value: MigrationTypeFromString | ExpressionInitialiser<{}, MigrationTypeFromString>) { return this.addStandaloneOperation(AlterColumnSubStatement.setDefault(value)); } @@ -204,9 +204,9 @@ class AlterColumnSubStatement extends Statement { /** * Warning: Do not use this outside of migrations */ - public static setDefault (value: TypeFromString | ExpressionInitialiser<{}, TypeFromString>) { + public static setDefault (value: MigrationTypeFromString | ExpressionInitialiser<{}, MigrationTypeFromString>) { const expr = typeof value === "function" ? Expression.compile(value) : undefined; - const stringifiedValue = expr?.text ?? Expression.stringifyValue(value as TypeFromString); + const stringifiedValue = expr?.text ?? Expression.stringifyValueRaw(value as MigrationTypeFromString); return new AlterColumnSubStatement(`SET DEFAULT (${stringifiedValue})`, expr?.values); }