From 159db6852b90fb9b02db619880be397b946cdf2c Mon Sep 17 00:00:00 2001 From: Jakub Knejzlik Date: Thu, 8 Feb 2024 10:23:15 +0100 Subject: [PATCH] Add CTAS and CVAS operations --- src/CreateTableAsSelect.test.ts | 49 +++++++++++++++++++++++++ src/CreateTableAsSelect.ts | 50 ++++++++++++++++++++++++++ src/CreateViewAsSelect.test.ts | 54 ++++++++++++++++++++++++++++ src/CreateViewAsSelect.ts | 63 +++++++++++++++++++++++++++++++++ src/Mutation-metadata.test.ts | 13 +++++-- src/Mutation.ts | 20 +++++------ src/Query-metadata.test.ts | 3 +- src/Query.ts | 28 ++++++++++----- src/interfaces.ts | 10 +++++- 9 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 src/CreateTableAsSelect.test.ts create mode 100644 src/CreateTableAsSelect.ts create mode 100644 src/CreateViewAsSelect.test.ts create mode 100644 src/CreateViewAsSelect.ts diff --git a/src/CreateTableAsSelect.test.ts b/src/CreateTableAsSelect.test.ts new file mode 100644 index 0000000..65d9082 --- /dev/null +++ b/src/CreateTableAsSelect.test.ts @@ -0,0 +1,49 @@ +import { CreateTableAsSelect } from "./CreateTableAsSelect"; +import { Cond } from "./Condition"; +import { Q } from "./Query"; +import { MySQLFlavor } from "./flavors/mysql"; +import { MetadataOperationType } from "./interfaces"; + +describe("CreateTableAsSelect", () => { + const initialSelectQuery = Q.select() + .from("users", "u") + .where(Cond.equal("u.id", 1)); + const tableName = "new_users_table"; + + it("should clone itself correctly", () => { + const ctas = Q.createTableAs(tableName, initialSelectQuery); + const clone = ctas.clone(); + + expect(clone).not.toBe(ctas); + expect(clone.toSQL(new MySQLFlavor())).toBe(ctas.toSQL(new MySQLFlavor())); + }); + + it("should generate the correct SQL", () => { + const ctas = Q.createTableAs(tableName, initialSelectQuery); + const expectedSQL = `CREATE TABLE \`${tableName}\` AS SELECT * FROM \`users\` AS \`u\` WHERE \`u\`.\`id\` = 1`; + expect(ctas.toSQL(new MySQLFlavor())).toBe(expectedSQL); + }); + + it("should serialize and deserialize correctly", () => { + const ctas = Q.createTableAs(tableName, initialSelectQuery); + const serialized = ctas.serialize(); + const deserialized = Q.deserialize(serialized); + + expect(deserialized).toEqual(ctas); + expect(deserialized.toSQL(new MySQLFlavor())).toBe( + ctas.toSQL(new MySQLFlavor()) + ); + }); + + it("should fetch table names correctly", () => { + const ctas = Q.createTableAs(tableName, initialSelectQuery); + expect(ctas.getTableNames()).toEqual([tableName, "users"]); + }); + + it("should return correct operation type", () => { + const ctas = Q.createTableAs(tableName, initialSelectQuery); + expect(ctas.getOperationType()).toEqual( + MetadataOperationType.CREATE_TABLE_AS + ); + }); +}); diff --git a/src/CreateTableAsSelect.ts b/src/CreateTableAsSelect.ts new file mode 100644 index 0000000..5fe55b0 --- /dev/null +++ b/src/CreateTableAsSelect.ts @@ -0,0 +1,50 @@ +import { Condition, ConditionValue } from "./Condition"; +import { ISQLFlavor } from "./Flavor"; +import { Q, SelectQuery, Table } from "./Query"; +import { MySQLFlavor } from "./flavors/mysql"; +import { + IMetadata, + ISequelizable, + ISerializable, + MetadataOperationType, +} from "./interfaces"; + +export class CreateTableAsSelect + implements ISerializable, ISequelizable, IMetadata +{ + constructor(private _tableName: string, private _select: SelectQuery) {} + + public clone(): this { + return new (this.constructor as any)(this._tableName, this._select.clone()); + } + + getOperationType(): MetadataOperationType { + return MetadataOperationType.CREATE_TABLE_AS; + } + + getTableNames(): string[] { + return [this._tableName, ...this._select.getTableNames()]; + } + + toSQL(flavor: ISQLFlavor = new MySQLFlavor()): string { + return `CREATE TABLE ${flavor.escapeTable( + this._tableName + )} AS ${this._select.toSQL(flavor)}`; + } + + serialize(): string { + return JSON.stringify(this.toJSON()); + } + + toJSON() { + return { + type: MetadataOperationType.CREATE_TABLE_AS, + select: this._select.toJSON(), + tableName: this._tableName, + }; + } + + static fromJSON({ tableName, select }: any): CreateTableAsSelect { + return new CreateTableAsSelect(tableName, SelectQuery.fromJSON(select)); + } +} diff --git a/src/CreateViewAsSelect.test.ts b/src/CreateViewAsSelect.test.ts new file mode 100644 index 0000000..e8f4e4e --- /dev/null +++ b/src/CreateViewAsSelect.test.ts @@ -0,0 +1,54 @@ +import { CreateViewAsSelect } from "./CreateViewAsSelect"; // Adjust the import path as needed +import { Cond } from "./Condition"; +import { Q } from "./Query"; +import { MySQLFlavor } from "./flavors/mysql"; +import { MetadataOperationType } from "./interfaces"; + +describe("CreateViewAsSelect", () => { + const initialSelectQuery = Q.select() + .from("users", "u") + .where(Cond.equal("u.id", 1)); + const viewName = "user_view"; + + it("should clone itself correctly", () => { + const cvas = Q.createOrReaplaceViewAs(viewName, initialSelectQuery); + const clone = cvas.clone(); + + expect(clone).not.toBe(cvas); + expect(clone.toSQL(new MySQLFlavor())).toBe(cvas.toSQL(new MySQLFlavor())); + }); + + it("should generate the correct SQL with OR REPLACE", () => { + const cvas = Q.createOrReaplaceViewAs(viewName, initialSelectQuery); + const expectedSQL = `CREATE OR REPLACE VIEW \`${viewName}\` AS SELECT * FROM \`users\` AS \`u\` WHERE \`u\`.\`id\` = 1`; + expect(cvas.toSQL(new MySQLFlavor())).toBe(expectedSQL); + }); + + it("should generate the correct SQL without OR REPLACE", () => { + const cvas = Q.createViewAs(viewName, initialSelectQuery); + const expectedSQL = `CREATE VIEW \`${viewName}\` AS SELECT * FROM \`users\` AS \`u\` WHERE \`u\`.\`id\` = 1`; + expect(cvas.toSQL(new MySQLFlavor())).toBe(expectedSQL); + }); + + it("should serialize and deserialize correctly", () => { + const cvas = Q.createTableAs(viewName, initialSelectQuery); + const serialized = cvas.serialize(); + const deserialized = Q.deserialize(serialized); + + expect(deserialized.toSQL(new MySQLFlavor())).toEqual( + cvas.toSQL(new MySQLFlavor()) + ); + }); + + it("should fetch table names correctly", () => { + const cvas = Q.createViewAs(viewName, initialSelectQuery); + expect(cvas.getTableNames()).toEqual([viewName, "users"]); + }); + + it("should return correct operation type", () => { + const cvas = Q.createViewAs(viewName, initialSelectQuery); + expect(cvas.getOperationType()).toEqual( + MetadataOperationType.CREATE_VIEW_AS + ); + }); +}); diff --git a/src/CreateViewAsSelect.ts b/src/CreateViewAsSelect.ts new file mode 100644 index 0000000..6b75305 --- /dev/null +++ b/src/CreateViewAsSelect.ts @@ -0,0 +1,63 @@ +import { SelectQuery } from "./Query"; +import { ISQLFlavor } from "./Flavor"; +import { MySQLFlavor } from "./flavors/mysql"; +import { + IMetadata, + ISequelizable, + ISerializable, + MetadataOperationType, +} from "./interfaces"; + +export class CreateViewAsSelect + implements ISerializable, ISequelizable, IMetadata +{ + constructor( + private _viewName: string, + private _select: SelectQuery, + private _orReplace: boolean = false + ) {} + + public clone(): this { + return new (this.constructor as any)( + this._viewName, + this._select.clone(), + this._orReplace + ); + } + + getOperationType(): MetadataOperationType { + return MetadataOperationType.CREATE_VIEW_AS; + } + + getTableNames(): string[] { + return [this._viewName, ...this._select.getTableNames()]; + } + + toSQL(flavor: ISQLFlavor = new MySQLFlavor()): string { + const orReplaceStr = this._orReplace ? "OR REPLACE " : ""; + return `CREATE ${orReplaceStr}VIEW ${flavor.escapeTable( + this._viewName + )} AS ${this._select.toSQL(flavor)}`; + } + + serialize(): string { + return JSON.stringify(this.toJSON()); + } + + toJSON() { + return { + type: MetadataOperationType.CREATE_VIEW_AS, + select: this._select.toJSON(), + viewName: this._viewName, + orReplace: this._orReplace, + }; + } + + static fromJSON({ viewName, select, orReplace }: any): CreateViewAsSelect { + return new CreateViewAsSelect( + viewName, + SelectQuery.fromJSON(select), + orReplace + ); + } +} diff --git a/src/Mutation-metadata.test.ts b/src/Mutation-metadata.test.ts index 2ba1e57..604adc0 100644 --- a/src/Mutation-metadata.test.ts +++ b/src/Mutation-metadata.test.ts @@ -1,4 +1,5 @@ import { Q } from "./Query"; +import { MetadataOperationType } from "./interfaces"; describe("Query builder metadata", () => { it("should return list of tables in insert query", () => { @@ -17,8 +18,14 @@ describe("Query builder metadata", () => { expect(tables).toEqual(["table"]); }); it("should get operation type", () => { - expect(Q.insert("table").getOperationType()).toEqual("insert"); - expect(Q.update("table").getOperationType()).toEqual("update"); - expect(Q.delete("table").getOperationType()).toEqual("delete"); + expect(Q.insert("table").getOperationType()).toEqual( + MetadataOperationType.INSERT + ); + expect(Q.update("table").getOperationType()).toEqual( + MetadataOperationType.UPDATE + ); + expect(Q.delete("table").getOperationType()).toEqual( + MetadataOperationType.DELETE + ); }); }); diff --git a/src/Mutation.ts b/src/Mutation.ts index 70482b5..6f47568 100644 --- a/src/Mutation.ts +++ b/src/Mutation.ts @@ -28,12 +28,12 @@ export class MutationBase { static deserialize(json: string) { const parsed = JSON.parse(json); - switch (parsed.type) { - case "DeleteMutation": + switch (parsed.type as MetadataOperationType) { + case MetadataOperationType.DELETE: return DeleteMutation.fromJSON(parsed); - case "InsertMutation": + case MetadataOperationType.INSERT: return InsertMutation.fromJSON(parsed); - case "UpdateMutation": + case MetadataOperationType.UPDATE: return UpdateMutation.fromJSON(parsed); default: throw new Error("Unknown mutation type"); @@ -48,7 +48,7 @@ export class DeleteMutation protected _where: Condition[] = []; public getOperationType(): MetadataOperationType { - return "delete"; + return MetadataOperationType.DELETE; } public clone(): this { @@ -79,7 +79,7 @@ export class DeleteMutation toJSON() { return { - type: "DeleteMutation", + type: MetadataOperationType.DELETE, table: this._table.toJSON(), where: this._where.map((condition) => condition.toJSON()), }; @@ -101,7 +101,7 @@ export class InsertMutation protected _values: Record = {}; public getOperationType(): MetadataOperationType { - return "insert"; + return MetadataOperationType.INSERT; } public clone(): this { @@ -134,7 +134,7 @@ export class InsertMutation toJSON() { return { - type: "InsertMutation", + type: MetadataOperationType.INSERT, table: this._table.toJSON(), values: this._values, }; @@ -155,7 +155,7 @@ export class UpdateMutation protected _where: Condition[] = []; public getOperationType(): MetadataOperationType { - return "update"; + return MetadataOperationType.UPDATE; } public clone(): this { @@ -202,7 +202,7 @@ export class UpdateMutation toJSON() { return { - type: "UpdateMutation", + type: MetadataOperationType.UPDATE, table: this._table.toJSON(), values: this._values, where: this._where.map((condition) => condition.toJSON()), diff --git a/src/Query-metadata.test.ts b/src/Query-metadata.test.ts index 5d1957b..5080e3d 100644 --- a/src/Query-metadata.test.ts +++ b/src/Query-metadata.test.ts @@ -1,5 +1,6 @@ import { Q } from "./Query"; import { Fn } from "./Function"; +import { MetadataOperationType } from "./interfaces"; describe("Query builder metadata", () => { it("should return list of tables in simple query", () => { @@ -59,6 +60,6 @@ describe("Query builder metadata", () => { it("should get operation type for query", () => { const query = Q.select(); const operation = query.getOperationType(); - expect(operation).toEqual("select"); + expect(operation).toEqual(MetadataOperationType.SELECT); }); }); diff --git a/src/Query.ts b/src/Query.ts index faed615..45d1939 100644 --- a/src/Query.ts +++ b/src/Query.ts @@ -1,4 +1,6 @@ import { Condition } from "./Condition"; +import { CreateTableAsSelect } from "./CreateTableAsSelect"; +import { CreateViewAsSelect } from "./CreateViewAsSelect"; import { Expression, ExpressionValue } from "./Expression"; import { ISQLFlavor } from "./Flavor"; import { AWSTimestreamFlavor } from "./flavors/aws-timestream"; @@ -48,7 +50,7 @@ export class Table implements ISequelizable, ISerializable { static fromJSON(json: any): Table { if ( typeof json.source === "object" && - json.source["type"] === "SelectQuery" + json.source["type"] === MetadataOperationType.SELECT ) { return new Table(SelectQuery.fromJSON(json.source), json.alias); } @@ -75,7 +77,7 @@ export class QueryBase implements ISequelizable, IMetadata { protected _joins: Join[] = []; public getOperationType(): MetadataOperationType { - return "select"; + return MetadataOperationType.SELECT; } // @ts-ignore @@ -414,7 +416,7 @@ export class SelectQuery extends SelectBaseQuery implements ISerializable { } toJSON(): any { return { - type: "SelectQuery", + type: MetadataOperationType.SELECT, tables: this._tables.map((table) => typeof table === "string" ? table : table.toJSON() ), @@ -474,15 +476,19 @@ export class SelectQuery extends SelectBaseQuery implements ISerializable { const deserialize = (json: string) => { try { const parsed = JSON.parse(json); - switch (parsed.type) { - case "SelectQuery": + switch (parsed.type as MetadataOperationType) { + case MetadataOperationType.SELECT: return SelectQuery.fromJSON(parsed); - case "DeleteMutation": + case MetadataOperationType.DELETE: return DeleteMutation.fromJSON(parsed); - case "InsertMutation": + case MetadataOperationType.INSERT: return InsertMutation.fromJSON(parsed); - case "UpdateMutation": + case MetadataOperationType.UPDATE: return UpdateMutation.fromJSON(parsed); + case MetadataOperationType.CREATE_TABLE_AS: + return CreateTableAsSelect.fromJSON(parsed); + case MetadataOperationType.CREATE_VIEW_AS: + return CreateViewAsSelect.fromJSON(parsed); default: throw new Error("Unknown mutation type"); } @@ -500,6 +506,12 @@ export const Query = { 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), + createOrReaplaceViewAs: (table: string, select: SelectQuery) => + new CreateViewAsSelect(table, select, true), deserialize, flavors, expr: Expression.deserialize, diff --git a/src/interfaces.ts b/src/interfaces.ts index 2170e66..748eed5 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -7,7 +7,15 @@ export interface ISerializable { serialize(): string; } -export type MetadataOperationType = "select" | "insert" | "update" | "delete"; +export enum MetadataOperationType { + SELECT = "Select", + INSERT = "Insert", + UPDATE = "Update", + DELETE = "Delete", + CREATE_TABLE_AS = "CTAS", + CREATE_VIEW_AS = "CTAS", +} + export interface IMetadata { getTableNames(): string[]; getOperationType(): MetadataOperationType;