UNPKG

@jakub.knejzlik/ts-query

Version:

TypeScript implementation of SQL builder

621 lines (575 loc) 17.5 kB
import { Dayjs, isDayjs } from "dayjs"; import { Condition } from "./Condition"; import { CreateTableAsSelect } from "./CreateTableAsSelect"; import { CreateViewAsSelect } from "./CreateViewAsSelect"; import { Expression, ExpressionBase, ExpressionRawValue, ExpressionValue, RawExpression, } from "./Expression"; import { ISQLFlavor } from "./Flavor"; import { AWSTimestreamFlavor } from "./flavors/aws-timestream"; import { MySQLFlavor } from "./flavors/mysql"; import { SQLiteFlavor } from "./flavors/sqlite"; import { Fn } from "./Function"; import { IMetadata, ISequelizable, ISequelizableOptions, ISerializable, MetadataOperationType, OperationType, } from "./interfaces"; import { DeleteMutation, InsertMutation, UpdateMutation } from "./Mutation"; export type InputValue = ExpressionValue | Dayjs; const flavors = { mysql: new MySQLFlavor(), awsTimestream: new AWSTimestreamFlavor(), sqlite: new SQLiteFlavor(), }; export type TableSource = string | SelectQuery; export class Table implements ISequelizable, ISerializable { constructor(public source: TableSource, public alias?: string) {} public clone(): this { return new (this.constructor as any)(this.source, this.alias); } public getTableName(): string | undefined { if (typeof this.source === "string") { return this.source; } return this.source.table?.getTableName(); } toSQL(flavor: ISQLFlavor, options?: ISequelizableOptions): string { let table = this.source; if (!isSelectQuery(table) && options?.transformTable) { table = options.transformTable(table); options = { ...options, transformTable: undefined }; } const isSelect = isSelectQuery(table); let alias = this.alias; const tableName = escapeTable(table, flavor, options); if (isSelect && !alias) alias = "t"; return `${tableName}${alias ? ` AS ${flavor.escapeColumn(alias)}` : ""}`; } toJSON(): any { return { type: "Table", source: this.source, alias: this.alias, }; } static fromJSON(json: any): Table { if ( typeof json.source === "object" && json.source["type"] === OperationType.SELECT ) { return new Table(SelectQuery.fromJSON(json.source), json.alias); } return new Table(json.source, json.alias); } serialize(): string { return JSON.stringify(this.toJSON()); } static deserialize(json: string): Table { return Table.fromJSON(JSON.parse(json)); } } function isSelectQuery(table: TableSource): table is SelectQuery { return table instanceof SelectQuery; } export const escapeTable = ( table: TableSource, flavor: ISQLFlavor, options?: ISequelizableOptions ): string => { if (isSelectQuery(table)) return `(${table.toSQL(flavor, options)})`; return flavor.escapeTable(table); }; export class QueryBase implements ISequelizable, IMetadata { protected _tables: Table[] = []; protected _joins?: Join[] = []; public getOperationType(): MetadataOperationType { return MetadataOperationType.SELECT; } // @ts-ignore public get table(): Table | undefined { if (this._tables.length === 0) return undefined; return this._tables[0]; } public get tables(): Table[] { return this._tables; } public from(table: TableSource, alias?: string): this { const clone = this.clone(); if (isSelectQuery(table)) { clone._tables = [new Table(table.clone(), alias)]; } else { clone._tables = [new Table(table, alias)]; } return clone; } public getTableNames(): string[] { return [ ...this._tables.map((t) => t.getTableName()).filter((x) => x), ...this._joins.map((j) => j.getTableName()).filter((x) => x), ]; } /** * join function to join tables with all join types */ join(table: Table, condition?: Condition, type: JoinType = "INNER"): this { const clone = this.clone(); clone._joins.push(new Join(table, condition, type)); return clone; } innerJoin(table: Table, condition: Condition): this { return this.join(table, condition, "INNER"); } leftJoin(table: Table, condition: Condition): this { return this.join(table, condition, "LEFT"); } rightJoin(table: Table, condition: Condition): this { return this.join(table, condition, "RIGHT"); } fullJoin(table: Table, condition: Condition): this { return this.join(table, condition, "FULL"); } crossJoin(table: Table, condition: Condition): this { return this.join(table, condition, "CROSS"); } public clone(): this { const clone = new (this.constructor as any)(); clone._tables = [...this._tables.map((t) => t.clone())]; clone._joins = [...this._joins.map((j) => j.clone())]; return clone; } toSQL(flavor: ISQLFlavor, options?: ISequelizableOptions): string { return this.tables.length > 0 ? `FROM ${this.tables .map((table) => table.toSQL(flavor, options)) .join(",")}` : ""; } } type JoinType = "INNER" | "LEFT" | "RIGHT" | "FULL" | "CROSS"; class Join { protected _type: JoinType; protected _table: Table; protected _condition?: Condition; constructor(table: Table, condition?: Condition, type: JoinType = "INNER") { this._table = table; this._condition = condition; this._type = type; } public clone(): this { const clone = new (this.constructor as any)( this._table, this._condition, this._type ); return clone; } public getTableName(): string { return this._table.getTableName(); } toSQL(flavor: ISQLFlavor, options?: ISequelizableOptions): string { return `${this._type} JOIN ${this._table.toSQL(flavor, options)}${ this._condition ? ` ON ${this._condition.toSQL(flavor)}` : "" }`; } toJSON(): any { return { type: "Join", table: this._table.toJSON(), condition: this._condition?.toJSON(), joinType: this._type, }; } static fromJSON(json: any): Join { return new Join( Table.fromJSON(json.table), json.condition && Condition.fromJSON(json.condition), json.joinType ); } serialize(): string { return JSON.stringify(this.toJSON()); } static deserialize(json: string): Join { return Join.fromJSON(JSON.parse(json)); } } interface SelectField { name: ExpressionValue; alias?: string; } interface Order { field: ExpressionBase; direction: "ASC" | "DESC"; } class SelectBaseQuery extends QueryBase { protected _fields: SelectField[] = []; public clone(): this { const clone = super.clone(); clone._fields = [...this._fields]; return clone; } // @deprecated please use addField field(name: ExpressionValue, alias?: string): this { return this.addFields([{ name, alias }]); } // add single field addField(name: ExpressionValue, alias?: string): this { return this.addFields([{ name, alias }]); } // add multiple fields addFields(fields: SelectField[]): this { const clone = this.clone(); clone._fields.push( ...fields.map((f) => ({ name: Expression.deserialize(f.name), alias: f.alias, })) ); return clone; } removeFields(): this { return this.fields([]); } // reset fields fields(fields: SelectField[]): this { const clone = this.clone(); clone._fields = []; return clone.addFields(fields); } toSQL(flavor: ISQLFlavor, options?: ISequelizableOptions): string { const columns = this._fields.length > 0 ? this._fields .map( (f) => `${Expression.deserialize(f.name).toSQL(flavor, options)}${ f.alias ? ` AS ${flavor.escapeColumn(f.alias)}` : "" }` ) .join(", ") : "*"; return `SELECT ${columns} ${super.toSQL(flavor, options)}`; } } export enum UnionType { UNION = "UNION", UNION_ALL = "UNION ALL", } export class SelectQuery extends SelectBaseQuery implements ISerializable { protected _where: Condition[] = []; protected _having: Condition[] = []; protected _limit?: number; protected _offset?: number; protected _orderBy: Order[] = []; protected _groupBy: ExpressionBase[] = []; protected _unionQueries: { query: SelectQuery; type: UnionType }[] = []; public clone(): this { const clone = super.clone(); clone._where = [...this._where]; clone._having = [...this._having]; clone._limit = this._limit; clone._offset = this._offset; clone._orderBy = [...this._orderBy]; clone._groupBy = [...this._groupBy]; clone._unionQueries = this._unionQueries.map((u) => ({ query: u.query.clone(), type: u.type, })); return clone; } where(condition: Condition | null): this { if (condition === null) return this; const clone = this.clone(); clone._where.push(condition); return clone; } removeWhere(): this { const clone = this.clone(); clone._where = []; return clone; } having(condition: Condition): this { const clone = this.clone(); clone._having.push(condition); return clone; } removeHaving(): this { const clone = this.clone(); clone._having = []; return clone; } public getLimit(): number | undefined { return this._limit; } clearLimit(): this { const clone = this.clone(); clone._limit = undefined; return clone; } limit(limit: number): this { const clone = this.clone(); clone._limit = limit; return clone; } public getOffset(): number | undefined { return this._offset; } clearOffset(): this { const clone = this.clone(); clone._offset = undefined; return clone; } offset(offset: number): SelectQuery { const clone = this.clone(); clone._offset = offset; return clone; } public getOrderBy(): Order[] { return this._orderBy; } orderBy(field: ExpressionValue, direction: "ASC" | "DESC" = "ASC"): this { const clone = this.clone(); clone._orderBy.push({ field: Expression.deserialize(field), direction, }); return clone; } removeOrderBy(): this { const clone = this.clone(); clone._orderBy = []; return clone; } public getGroupBy(): ExpressionBase[] { return this._groupBy; } groupBy(...field: ExpressionValue[]): this { const clone = this.clone(); clone._groupBy.push(...field.map((f) => Expression.deserialize(f))); return clone; } removeGroupBy(): this { const clone = this.clone(); clone._groupBy = []; return clone; } public union(query: SelectQuery, type: UnionType = UnionType.UNION): this { const clone = this.clone(); clone._unionQueries.push({ query, type }); return clone; } public getTableNames(): string[] { return Array.from( new Set([ ...super.getTableNames(), ...this._unionQueries.reduce( (acc, u) => [...acc, ...u.query.getTableNames()], [] as string[] ), ]) ); } toSQL( flavor: ISQLFlavor = flavors.mysql, options?: ISequelizableOptions, transformProcessed = false ): string { let sql = ""; if (options?.transformSelectQuery && !transformProcessed) { const q = options.transformSelectQuery(this); return q.toSQL(flavor, options, true); } else { sql = super.toSQL(flavor, options); } if (this._joins?.length > 0) { sql += ` ${this._joins.map((j) => j.toSQL(flavor, options)).join(" ")}`; } if (this._where.length > 0) { sql += ` WHERE ${this._where.map((w) => w.toSQL(flavor)).join(" AND ")}`; } if (this._groupBy.length > 0) { sql += ` GROUP BY ${this._groupBy .map((c) => c.toSQL(flavor)) .join(", ")}`; } if (this._having.length > 0) { sql += ` HAVING ${this._having .map((w) => w.toSQL(flavor)) .join(" AND ")}`; } if (this._orderBy.length > 0) { sql += ` ORDER BY ${this._orderBy .map((o) => `${o.field.toSQL(flavor)} ${o.direction}`) .join(", ")}`; } sql += flavor.escapeLimitAndOffset(this._limit, this._offset); this._unionQueries.forEach((unionQuery) => { sql = flavor.escapeUnion( unionQuery.type, sql, unionQuery.query.toSQL(flavor, options) ); }); return sql; } // serialization serialize(): string { return JSON.stringify(this.toJSON()); } toJSON(): any { return { type: OperationType.SELECT, tables: this._tables.map((table) => typeof table === "string" ? table : table.toJSON() ), unionQueries: this._unionQueries.length > 0 ? this._unionQueries.map((u) => ({ type: u.type, query: u.query.toJSON(), })) : undefined, joins: this._joins.length > 0 ? this._joins.map((join) => join.toJSON()) : undefined, fields: this._fields.length > 0 ? this._fields.map((f) => ({ name: Expression.deserialize(f.name).serialize(), alias: f.alias, })) : undefined, where: this._where.length > 0 ? this._where.map((condition) => condition.toJSON()) : undefined, having: this._having.length > 0 ? this._having.map((condition) => condition.toJSON()) : undefined, orderBy: this._orderBy.length > 0 ? this._orderBy.map((o) => ({ field: o.field.serialize(), direction: o.direction, })) : undefined, groupBy: this._groupBy.length > 0 ? this._groupBy.map((c) => c.serialize()) : undefined, limit: this._limit, offset: this._offset, }; } static fromJSON(json: any): SelectQuery { const query = new SelectQuery(); query._tables = json.tables.map((table: any) => { return Table.fromJSON(table); }); query._unionQueries = (json.unionQueries || []).map((u: any) => ({ type: u.type, query: SelectQuery.fromJSON(u.query), })); query._joins = (json.joins || []).map((joinJson: any) => Join.fromJSON(joinJson) ); query._fields = (json.fields ?? []).map((field: any) => ({ name: Expression.deserialize(field.name), alias: field.alias, })); query._where = (json.where || []).map((conditionJson: any) => Condition.fromJSON(conditionJson) ); query._having = (json.having || []).map((conditionJson: any) => Condition.fromJSON(conditionJson) ); query._limit = json.limit; query._offset = json.offset; query._orderBy = (json.orderBy ?? []).map((o: any) => ({ field: Expression.deserialize(o.field), direction: o.direction, })); query._groupBy = (json.groupBy ?? []).map((v) => Expression.deserialize(v)); return query; } } const deserialize = (json: string) => { try { const parsed = JSON.parse(json); switch (parsed.type as OperationType) { case OperationType.SELECT: return SelectQuery.fromJSON(parsed); case OperationType.DELETE: return DeleteMutation.fromJSON(parsed); case OperationType.INSERT: return InsertMutation.fromJSON(parsed); case OperationType.UPDATE: return UpdateMutation.fromJSON(parsed); case OperationType.CREATE_TABLE_AS: return CreateTableAsSelect.fromJSON(parsed); case OperationType.CREATE_VIEW_AS: return CreateViewAsSelect.fromJSON(parsed); default: throw new Error("Unknown mutation type"); } } catch (e) { throw new Error(`Error parsing query: ${(e as Error).message}`); } }; const deserializeRaw = (json: string) => { try { return deserialize(json); } catch (e) { return Expression.deserialize(json); } }; const inputValueToExpressionValue = (val: InputValue): ExpressionValue => { if (isDayjs(val)) return val.toDate(); return val; }; export const Query = { table: (name: string, alias?: string) => new Table(name, alias), select: () => { return new SelectQuery(); }, stats: () => new SelectQuery().from("(?)", "t"), delete: (from: string, alias?: string) => new DeleteMutation(from, alias), update: (table: string, alias?: string) => new UpdateMutation(table, alias), insert: (into: string) => new InsertMutation(into), createTableAs: (table: string, select: SelectQuery) => new CreateTableAsSelect(table, select), createViewAs: (table: string, select: SelectQuery) => new CreateViewAsSelect(table, select), createOrReplaceViewAs: (table: string, select: SelectQuery) => new CreateViewAsSelect(table, select, true), deserialize, deserializeRaw, flavors, null: () => new RawExpression("NULL"), raw: (val: ExpressionRawValue) => new RawExpression(val), expr: (val: InputValue) => ExpressionBase.deserialize(inputValueToExpressionValue(val)), exprValue: (val: InputValue) => ExpressionBase.deserializeValue(inputValueToExpressionValue(val)), value: (val: InputValue) => ExpressionBase.deserializeValue(inputValueToExpressionValue(val)), column: (col: ExpressionRawValue) => Expression.escapeColumn(col), S: (literals: string | readonly string[]) => { return Fn.string(`${literals}`); }, string: (literals: string | readonly string[]) => { return Fn.string(`${literals}`); }, }; // for shorter syntax export { Query as Q };