diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fcdefb8..2b1aa35 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,6 +42,7 @@ export * from './transformers/Formatter'; export * from './transformers/SqlFormatter'; export * from './transformers/PostgresJsonQueryBuilder'; export * from './transformers/QueryBuilder'; // old name:QueryConverter +export * from './transformers/InsertQuerySelectValuesConverter'; export * from './transformers/SelectValueCollector'; export * from './transformers/SelectableColumnCollector'; export { DuplicateDetectionMode } from './transformers/SelectableColumnCollector'; diff --git a/packages/core/src/models/BinarySelectQuery.ts b/packages/core/src/models/BinarySelectQuery.ts index c8e0be0..8d3f5a0 100644 --- a/packages/core/src/models/BinarySelectQuery.ts +++ b/packages/core/src/models/BinarySelectQuery.ts @@ -1,13 +1,22 @@ -import { SourceExpression, SubQuerySource, SourceAliasExpression } from "./Clause"; -import type { SelectQuery, CTEOptions } from "./SelectQuery"; -import { SqlComponent } from "./SqlComponent"; -import { RawString, SqlParameterValue } from "./ValueComponent"; -import { CTENormalizer } from "../transformers/CTENormalizer"; -import { SelectQueryParser } from "../parsers/SelectQueryParser"; -import { ParameterCollector } from "../transformers/ParameterCollector"; -import { ParameterHelper } from "../utils/ParameterHelper"; -import { QueryBuilder } from "../transformers/QueryBuilder"; -import { SimpleSelectQuery } from "./SimpleSelectQuery"; +import { SourceExpression, SubQuerySource, SourceAliasExpression } from "./Clause"; +import type { + SelectQuery, + InsertQueryConversionOptions, + UpdateQueryConversionOptions, + DeleteQueryConversionOptions, + MergeQueryConversionOptions +} from "./SelectQuery"; +import { SqlComponent } from "./SqlComponent"; +import { RawString, SqlParameterValue } from "./ValueComponent"; +import { CTENormalizer } from "../transformers/CTENormalizer"; +import { SelectQueryParser } from "../parsers/SelectQueryParser"; +import { ParameterHelper } from "../utils/ParameterHelper"; +import { QueryBuilder } from "../transformers/QueryBuilder"; +import { SimpleSelectQuery } from "./SimpleSelectQuery"; +import type { InsertQuery } from "./InsertQuery"; +import type { UpdateQuery } from "./UpdateQuery"; +import type { DeleteQuery } from "./DeleteQuery"; +import type { MergeQuery } from "./MergeQuery"; /** * Represents a binary SELECT expression (UNION/INTERSECT/EXCEPT) composed from two SelectQuery values. @@ -154,11 +163,43 @@ export class BinarySelectQuery extends SqlComponent implements SelectQuery { const parsedQuery = SelectQueryParser.parse(sql); return this.except(parsedQuery); } - public exceptAllRaw(sql: string): BinarySelectQuery { - const parsedQuery = SelectQueryParser.parse(sql); - return this.exceptAll(parsedQuery); - } - + public exceptAllRaw(sql: string): BinarySelectQuery { + const parsedQuery = SelectQueryParser.parse(sql); + return this.exceptAll(parsedQuery); + } + + /** + * Converts this query into an INSERT statement definition. + * @remarks The underlying simple query may be reordered so that column order matches the requested insert columns. + */ + public toInsertQuery(options: InsertQueryConversionOptions): InsertQuery { + return this.toSimpleQuery().toInsertQuery(options); + } + + /** + * Converts this query into an UPDATE statement definition. + * @remarks The conversion can reorder the SELECT list produced by {@link toSimpleQuery}. + */ + public toUpdateQuery(options: UpdateQueryConversionOptions): UpdateQuery { + return this.toSimpleQuery().toUpdateQuery(options); + } + + /** + * Converts this query into a DELETE statement definition. + * @remarks The conversion can reorder the SELECT list produced by {@link toSimpleQuery}. + */ + public toDeleteQuery(options: DeleteQueryConversionOptions): DeleteQuery { + return this.toSimpleQuery().toDeleteQuery(options); + } + + /** + * Converts this query into a MERGE statement definition. + * @remarks The conversion can reorder the SELECT list produced by {@link toSimpleQuery}. + */ + public toMergeQuery(options: MergeQueryConversionOptions): MergeQuery { + return this.toSimpleQuery().toMergeQuery(options); + } + // Returns a SourceExpression wrapping this query as a subquery source. // Optionally takes an alias name (default: "subq") public toSource(alias: string = "subq"): SourceExpression { diff --git a/packages/core/src/models/SelectQuery.ts b/packages/core/src/models/SelectQuery.ts index 487252d..59acc38 100644 --- a/packages/core/src/models/SelectQuery.ts +++ b/packages/core/src/models/SelectQuery.ts @@ -1,9 +1,12 @@ -import { SqlComponent } from "./SqlComponent"; -import { InsertQuery } from "./InsertQuery"; -import { SimpleSelectQuery } from "./SimpleSelectQuery"; -import { BinarySelectQuery } from "./BinarySelectQuery"; -import { ValuesQuery } from "./ValuesQuery"; -import { SqlParameterValue } from "./ValueComponent"; +import { SqlComponent } from "./SqlComponent"; +import { InsertQuery } from "./InsertQuery"; +import { SimpleSelectQuery } from "./SimpleSelectQuery"; +import { BinarySelectQuery } from "./BinarySelectQuery"; +import { ValuesQuery } from "./ValuesQuery"; +import { SqlParameterValue } from "./ValueComponent"; +import { UpdateQuery } from "./UpdateQuery"; +import { DeleteQuery } from "./DeleteQuery"; +import { MergeQuery } from "./MergeQuery"; /** * Options that control how a Common Table Expression is materialized when the query is executed. @@ -29,13 +32,47 @@ export { DuplicateCTEError, InvalidCTENameError, CTENotFoundError } from './CTEE * Implementations are expected to surface the same error behaviour exercised in * packages/core/tests/models/SelectQuery.cte-management.test.ts. */ -export interface CTEManagement { - addCTE(name: string, query: SelectQuery, options?: CTEOptions): this; - removeCTE(name: string): this; - hasCTE(name: string): boolean; - getCTENames(): string[]; - replaceCTE(name: string, query: SelectQuery, options?: CTEOptions): this; -} +export interface CTEManagement { + addCTE(name: string, query: SelectQuery, options?: CTEOptions): this; + removeCTE(name: string): this; + hasCTE(name: string): boolean; + getCTENames(): string[]; + replaceCTE(name: string, query: SelectQuery, options?: CTEOptions): this; +} + +export interface InsertQueryConversionOptions { + target: string; + columns?: string[]; +} + +export interface UpdateQueryConversionOptions { + target: string; + primaryKeys: string | string[]; + columns?: string[]; + sourceAlias?: string; +} + +export interface DeleteQueryConversionOptions { + target: string; + primaryKeys: string | string[]; + columns?: string[]; + sourceAlias?: string; +} + +export type MergeMatchedAction = "update" | "delete" | "doNothing"; +export type MergeNotMatchedAction = "insert" | "doNothing"; +export type MergeNotMatchedBySourceAction = "delete" | "doNothing"; + +export interface MergeQueryConversionOptions { + target: string; + primaryKeys: string | string[]; + updateColumns?: string[]; + insertColumns?: string[]; + sourceAlias?: string; + matchedAction?: MergeMatchedAction; + notMatchedAction?: MergeNotMatchedAction; + notMatchedBySourceAction?: MergeNotMatchedBySourceAction; +} /** * Shared interface implemented by all select query variants. @@ -48,10 +85,22 @@ export interface CTEManagement { * ``` * Related tests: packages/core/tests/models/SelectQuery.toSimpleQuery.test.ts */ -export interface SelectQuery extends SqlComponent { - readonly __selectQueryType: 'SelectQuery'; // Discriminator property for type safety - headerComments: string[] | null; - setParameter(name: string, value: SqlParameterValue): this; - toSimpleQuery(): SimpleSelectQuery; -} -export { SimpleSelectQuery, BinarySelectQuery, ValuesQuery, InsertQuery }; +export interface SelectQuery extends SqlComponent { + readonly __selectQueryType: 'SelectQuery'; // Discriminator property for type safety + headerComments: string[] | null; + setParameter(name: string, value: SqlParameterValue): this; + toSimpleQuery(): SimpleSelectQuery; + toInsertQuery(options: InsertQueryConversionOptions): InsertQuery; + toUpdateQuery(options: UpdateQueryConversionOptions): UpdateQuery; + toDeleteQuery(options: DeleteQueryConversionOptions): DeleteQuery; + toMergeQuery(options: MergeQueryConversionOptions): MergeQuery; +} +export { + SimpleSelectQuery, + BinarySelectQuery, + ValuesQuery, + InsertQuery, + UpdateQuery, + DeleteQuery, + MergeQuery +}; diff --git a/packages/core/src/models/SimpleSelectQuery.ts b/packages/core/src/models/SimpleSelectQuery.ts index ad545b1..b970df8 100644 --- a/packages/core/src/models/SimpleSelectQuery.ts +++ b/packages/core/src/models/SimpleSelectQuery.ts @@ -5,15 +5,27 @@ import { ValueParser } from "../parsers/ValueParser"; import { CTENormalizer } from "../transformers/CTENormalizer"; import { SelectableColumnCollector } from "../transformers/SelectableColumnCollector"; import { SourceParser } from "../parsers/SourceParser"; -import { BinarySelectQuery } from "./BinarySelectQuery"; -import type { SelectQuery, CTEOptions, CTEManagement } from "./SelectQuery"; -import { DuplicateCTEError, InvalidCTENameError, CTENotFoundError } from "./CTEError"; -import { SelectQueryParser } from "../parsers/SelectQueryParser"; -import { Formatter } from "../transformers/Formatter"; -import { TableColumnResolver } from "../transformers/TableColumnResolver"; -import { UpstreamSelectQueryFinder } from "../transformers/UpstreamSelectQueryFinder"; -import { QueryBuilder } from "../transformers/QueryBuilder"; -import { ParameterHelper } from "../utils/ParameterHelper"; +import { BinarySelectQuery } from "./BinarySelectQuery"; +import type { + SelectQuery, + CTEOptions, + CTEManagement, + InsertQueryConversionOptions, + UpdateQueryConversionOptions, + DeleteQueryConversionOptions, + MergeQueryConversionOptions +} from "./SelectQuery"; +import { DuplicateCTEError, InvalidCTENameError, CTENotFoundError } from "./CTEError"; +import { SelectQueryParser } from "../parsers/SelectQueryParser"; +import { Formatter } from "../transformers/Formatter"; +import { TableColumnResolver } from "../transformers/TableColumnResolver"; +import { UpstreamSelectQueryFinder } from "../transformers/UpstreamSelectQueryFinder"; +import { QueryBuilder } from "../transformers/QueryBuilder"; +import { ParameterHelper } from "../utils/ParameterHelper"; +import type { InsertQuery } from "./InsertQuery"; +import type { UpdateQuery } from "./UpdateQuery"; +import type { DeleteQuery } from "./DeleteQuery"; +import type { MergeQuery } from "./MergeQuery"; /** * Represents a single SELECT statement with full clause support (WITH, JOIN, GROUP BY, etc.). @@ -114,10 +126,46 @@ export class SimpleSelectQuery extends SqlComponent implements SelectQuery, CTEM * @param rightQuery The right side of the UNION ALL * @returns A new BinarySelectQuery representing "this UNION ALL rightQuery" */ - public toUnionAll(rightQuery: SelectQuery): BinarySelectQuery { - return this.toBinaryQuery('union all', rightQuery); - } - + public toUnionAll(rightQuery: SelectQuery): BinarySelectQuery { + return this.toBinaryQuery('union all', rightQuery); + } + + /** + * Converts this query into an INSERT statement definition. + * @remarks + * Calling this method may reorder the current SELECT clause to match the requested column order. + */ + public toInsertQuery(options: InsertQueryConversionOptions): InsertQuery { + return QueryBuilder.buildInsertQuery(this, options); + } + + /** + * Converts this query into an UPDATE statement definition. + * @remarks + * The conversion may reorder the SELECT list so that primary keys and updated columns align with the target table. + */ + public toUpdateQuery(options: UpdateQueryConversionOptions): UpdateQuery { + return QueryBuilder.buildUpdateQuery(this, options); + } + + /** + * Converts this query into a DELETE statement definition. + * @remarks + * The SELECT clause may be reordered to ensure primary keys and comparison columns appear first. + */ + public toDeleteQuery(options: DeleteQueryConversionOptions): DeleteQuery { + return QueryBuilder.buildDeleteQuery(this, options); + } + + /** + * Converts this query into a MERGE statement definition. + * @remarks + * This method may reorder the SELECT clause to align with the specified MERGE column lists. + */ + public toMergeQuery(options: MergeQueryConversionOptions): MergeQuery { + return QueryBuilder.buildMergeQuery(this, options); + } + /** * Creates a new BinarySelectQuery with this query as the left side and the provided query as the right side, * using INTERSECT as the operator. diff --git a/packages/core/src/models/SqlPrintToken.ts b/packages/core/src/models/SqlPrintToken.ts index d5b92db..76f1d71 100644 --- a/packages/core/src/models/SqlPrintToken.ts +++ b/packages/core/src/models/SqlPrintToken.ts @@ -83,6 +83,12 @@ export enum SqlPrintTokenContainerType { ReturningClause = "ReturningClause", SetClauseItem = "SetClauseItem", CreateTableQuery = "CreateTableQuery", + MergeQuery = "MergeQuery", + MergeWhenClause = "MergeWhenClause", + MergeUpdateAction = "MergeUpdateAction", + MergeDeleteAction = "MergeDeleteAction", + MergeInsertAction = "MergeInsertAction", + MergeDoNothingAction = "MergeDoNothingAction", CommentBlock = "CommentBlock", // Container for comment tokens with conditional newlines // Add more as needed } diff --git a/packages/core/src/models/ValuesQuery.ts b/packages/core/src/models/ValuesQuery.ts index acad175..23568e0 100644 --- a/packages/core/src/models/ValuesQuery.ts +++ b/packages/core/src/models/ValuesQuery.ts @@ -1,11 +1,20 @@ -import { ParameterHelper } from "../utils/ParameterHelper"; -import { ParameterCollector } from "../transformers/ParameterCollector"; -import { QueryBuilder } from "../transformers/QueryBuilder"; -import { SelectQuery } from "./SelectQuery"; -import { SimpleSelectQuery } from "./SimpleSelectQuery"; -import { SqlParameterValue } from "./ValueComponent"; -import { SqlComponent } from "./SqlComponent"; -import { TupleExpression } from "./ValueComponent"; +import { ParameterHelper } from "../utils/ParameterHelper"; +import { QueryBuilder } from "../transformers/QueryBuilder"; +import type { + SelectQuery, + InsertQueryConversionOptions, + UpdateQueryConversionOptions, + DeleteQueryConversionOptions, + MergeQueryConversionOptions +} from "./SelectQuery"; +import { SimpleSelectQuery } from "./SimpleSelectQuery"; +import { SqlParameterValue } from "./ValueComponent"; +import { SqlComponent } from "./SqlComponent"; +import { TupleExpression } from "./ValueComponent"; +import type { InsertQuery } from "./InsertQuery"; +import type { UpdateQuery } from "./UpdateQuery"; +import type { DeleteQuery } from "./DeleteQuery"; +import type { MergeQuery } from "./MergeQuery"; /** * Represents a VALUES query in SQL. @@ -28,10 +37,42 @@ export class ValuesQuery extends SqlComponent implements SelectQuery { this.columnAliases = columnAliases; } - public toSimpleQuery(): SimpleSelectQuery { - return QueryBuilder.buildSimpleQuery(this); - } - + public toSimpleQuery(): SimpleSelectQuery { + return QueryBuilder.buildSimpleQuery(this); + } + + /** + * Converts this VALUES query into an INSERT statement definition. + * @remarks The conversion may reorder the generated SELECT clause to align with the requested column order. + */ + public toInsertQuery(options: InsertQueryConversionOptions): InsertQuery { + return this.toSimpleQuery().toInsertQuery(options); + } + + /** + * Converts this VALUES query into an UPDATE statement definition. + * @remarks The conversion may reorder the generated SELECT clause to align with the requested column order. + */ + public toUpdateQuery(options: UpdateQueryConversionOptions): UpdateQuery { + return this.toSimpleQuery().toUpdateQuery(options); + } + + /** + * Converts this VALUES query into a DELETE statement definition. + * @remarks The conversion may reorder the generated SELECT clause to align with the requested column order. + */ + public toDeleteQuery(options: DeleteQueryConversionOptions): DeleteQuery { + return this.toSimpleQuery().toDeleteQuery(options); + } + + /** + * Converts this VALUES query into a MERGE statement definition. + * @remarks The conversion may reorder the generated SELECT clause to align with the requested column order. + */ + public toMergeQuery(options: MergeQueryConversionOptions): MergeQuery { + return this.toSimpleQuery().toMergeQuery(options); + } + /** * Sets the value of a parameter by name in this query. * @param name Parameter name diff --git a/packages/core/src/parsers/SqlPrintTokenParser.ts b/packages/core/src/parsers/SqlPrintTokenParser.ts index dd06c47..08e5174 100644 --- a/packages/core/src/parsers/SqlPrintTokenParser.ts +++ b/packages/core/src/parsers/SqlPrintTokenParser.ts @@ -40,6 +40,7 @@ import { InsertQuery } from "../models/InsertQuery"; import { UpdateQuery } from "../models/UpdateQuery"; import { DeleteQuery } from "../models/DeleteQuery"; import { CreateTableQuery } from "../models/CreateTableQuery"; +import { MergeQuery, MergeWhenClause, MergeUpdateAction, MergeDeleteAction, MergeInsertAction, MergeDoNothingAction, MergeMatchType } from "../models/MergeQuery"; export enum ParameterStyle { Anonymous = 'anonymous', @@ -310,6 +311,12 @@ export class SqlPrintTokenParser implements SqlComponentVisitor { this.handlers.set(SetClauseItem.kind, (expr) => this.visitSetClauseItem(expr as SetClauseItem)); this.handlers.set(ReturningClause.kind, (expr) => this.visitReturningClause(expr as ReturningClause)); this.handlers.set(CreateTableQuery.kind, (expr) => this.visitCreateTableQuery(expr as CreateTableQuery)); + this.handlers.set(MergeQuery.kind, (expr) => this.visitMergeQuery(expr as MergeQuery)); + this.handlers.set(MergeWhenClause.kind, (expr) => this.visitMergeWhenClause(expr as MergeWhenClause)); + this.handlers.set(MergeUpdateAction.kind, (expr) => this.visitMergeUpdateAction(expr as MergeUpdateAction)); + this.handlers.set(MergeDeleteAction.kind, (expr) => this.visitMergeDeleteAction(expr as MergeDeleteAction)); + this.handlers.set(MergeInsertAction.kind, (expr) => this.visitMergeInsertAction(expr as MergeInsertAction)); + this.handlers.set(MergeDoNothingAction.kind, (expr) => this.visitMergeDoNothingAction(expr as MergeDoNothingAction)); } /** @@ -2383,6 +2390,134 @@ export class SqlPrintTokenParser implements SqlComponentVisitor { return token; } + private visitMergeQuery(arg: MergeQuery): SqlPrintToken { + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.MergeQuery); + + if (arg.withClause) { + token.innerTokens.push(arg.withClause.accept(this)); + } + + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'merge into')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.target.accept(this)); + + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'using')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.source.accept(this)); + + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'on')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.onCondition.accept(this)); + + for (const clause of arg.whenClauses) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(clause.accept(this)); + } + + return token; + } + + private visitMergeWhenClause(arg: MergeWhenClause): SqlPrintToken { + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.MergeWhenClause); + + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, this.mergeMatchTypeToKeyword(arg.matchType))); + if (arg.condition) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'and')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.condition.accept(this)); + } + + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'then')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.action.accept(this)); + + return token; + } + + private visitMergeUpdateAction(arg: MergeUpdateAction): SqlPrintToken { + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.MergeUpdateAction); + + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'update')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.setClause.accept(this)); + + if (arg.whereClause) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.whereClause.accept(this)); + } + + return token; + } + + private visitMergeDeleteAction(arg: MergeDeleteAction): SqlPrintToken { + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.MergeDeleteAction); + + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'delete')); + if (arg.whereClause) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.whereClause.accept(this)); + } + + return token; + } + + private visitMergeInsertAction(arg: MergeInsertAction): SqlPrintToken { + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.MergeInsertAction); + + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'insert')); + if (arg.columns && arg.columns.length > 0) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(SqlPrintTokenParser.PAREN_OPEN_TOKEN); + for (let i = 0; i < arg.columns.length; i++) { + if (i > 0) { + token.innerTokens.push(...SqlPrintTokenParser.commaSpaceTokens()); + } + token.innerTokens.push(arg.columns[i].accept(this)); + } + token.innerTokens.push(SqlPrintTokenParser.PAREN_CLOSE_TOKEN); + } + + if (arg.defaultValues) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'default values')); + return token; + } + + if (arg.values) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'values')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(SqlPrintTokenParser.PAREN_OPEN_TOKEN); + token.innerTokens.push(arg.values.accept(this)); + token.innerTokens.push(SqlPrintTokenParser.PAREN_CLOSE_TOKEN); + } + + return token; + } + + private visitMergeDoNothingAction(_: MergeDoNothingAction): SqlPrintToken { + return new SqlPrintToken(SqlPrintTokenType.keyword, 'do nothing', SqlPrintTokenContainerType.MergeDoNothingAction); + } + + private mergeMatchTypeToKeyword(matchType: MergeMatchType): string { + switch (matchType) { + case 'matched': + return 'when matched'; + case 'not_matched': + return 'when not matched'; + case 'not_matched_by_source': + return 'when not matched by source'; + case 'not_matched_by_target': + return 'when not matched by target'; + default: + return 'when'; + } + } + private visitUpdateQuery(arg: UpdateQuery): SqlPrintToken { const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.UpdateQuery); diff --git a/packages/core/src/transformers/InsertQuerySelectValuesConverter.ts b/packages/core/src/transformers/InsertQuerySelectValuesConverter.ts new file mode 100644 index 0000000..886cb7c --- /dev/null +++ b/packages/core/src/transformers/InsertQuerySelectValuesConverter.ts @@ -0,0 +1,126 @@ +import { InsertQuery } from "../models/InsertQuery"; +import { ValuesQuery } from "../models/ValuesQuery"; +import { SimpleSelectQuery } from "../models/SimpleSelectQuery"; +import { BinarySelectQuery } from "../models/BinarySelectQuery"; +import { SelectClause, SelectItem } from "../models/Clause"; +import { TupleExpression, ValueComponent } from "../models/ValueComponent"; +import type { SelectQuery } from "../models/SelectQuery"; + +/** + * Utility to convert INSERT ... VALUES statements into INSERT ... SELECT UNION ALL form and vice versa. + * Enables easier column-by-column comparison across multi-row inserts. + */ +export class InsertQuerySelectValuesConverter { + /** + * Converts an INSERT query that uses VALUES into an equivalent INSERT ... SELECT UNION ALL form. + * The original InsertQuery remains untouched; the returned InsertQuery references cloned structures. + */ + public static toSelectUnion(insertQuery: InsertQuery): InsertQuery { + const valuesQuery = insertQuery.selectQuery; + if (!(valuesQuery instanceof ValuesQuery)) { + throw new Error("InsertQuery selectQuery is not a VALUES query."); + } + if (!valuesQuery.tuples.length) { + throw new Error("VALUES query does not contain any tuples."); + } + + const columns = insertQuery.insertClause.columns; + if (!columns || columns.length === 0) { + throw new Error("Cannot convert to SELECT form without explicit column list."); + } + + const columnNames = columns.map(col => col.name); + const selectQueries: SimpleSelectQuery[] = valuesQuery.tuples.map(tuple => { + if (tuple.values.length !== columnNames.length) { + throw new Error("Tuple value count does not match column count."); + } + const items = columnNames.map((name, idx) => new SelectItem(tuple.values[idx], name)); + const selectClause = new SelectClause(items); + return new SimpleSelectQuery({ selectClause }); + }); + + let combined: SelectQuery = selectQueries[0]; + for (let i = 1; i < selectQueries.length; i++) { + if (combined instanceof SimpleSelectQuery) { + combined = combined.toUnionAll(selectQueries[i]); + } else if (combined instanceof BinarySelectQuery) { + combined.appendSelectQuery("union all", selectQueries[i]); + } else { + throw new Error("Unsupported SelectQuery type during UNION ALL construction."); + } + } + + return new InsertQuery({ + withClause: insertQuery.withClause, + insertClause: insertQuery.insertClause, + selectQuery: combined, + returning: insertQuery.returningClause + }); + } + + /** + * Converts an INSERT query that leverages SELECT statements (with optional UNION ALL) + * into an equivalent INSERT ... VALUES representation. + */ + public static toValues(insertQuery: InsertQuery): InsertQuery { + const columns = insertQuery.insertClause.columns; + if (!columns || columns.length === 0) { + throw new Error("Cannot convert to VALUES form without explicit column list."); + } + if (!insertQuery.selectQuery) { + throw new Error("InsertQuery does not have a selectQuery to convert."); + } + + const columnNames = columns.map(col => col.name); + const simpleQueries = this.flattenSelectQueries(insertQuery.selectQuery); + if (!simpleQueries.length) { + throw new Error("No SELECT components found to convert."); + } + + const tuples = simpleQueries.map(query => { + if (query.fromClause || (query.whereClause && query.whereClause.condition)) { + throw new Error("SELECT queries with FROM or WHERE clauses cannot be converted to VALUES."); + } + const valueMap = new Map(); + for (const item of query.selectClause.items) { + const identifier = item.identifier?.name ?? null; + if (!identifier) { + throw new Error("Each SELECT item must have an alias matching target columns."); + } + if (!valueMap.has(identifier)) { + valueMap.set(identifier, item.value); + } + } + + const rowValues = columnNames.map(name => { + const value = valueMap.get(name); + if (!value) { + throw new Error(`Column '${name}' is not provided by the SELECT query.`); + } + return value; + }); + return new TupleExpression(rowValues); + }); + + const valuesQuery = new ValuesQuery(tuples, columnNames); + return new InsertQuery({ + withClause: insertQuery.withClause, + insertClause: insertQuery.insertClause, + selectQuery: valuesQuery, + returning: insertQuery.returningClause + }); + } + + private static flattenSelectQueries(selectQuery: SelectQuery): SimpleSelectQuery[] { + if (selectQuery instanceof SimpleSelectQuery) { + return [selectQuery]; + } + if (selectQuery instanceof BinarySelectQuery) { + return [ + ...this.flattenSelectQueries(selectQuery.left), + ...this.flattenSelectQueries(selectQuery.right) + ]; + } + throw new Error("Unsupported SelectQuery subtype for conversion."); + } +} diff --git a/packages/core/src/transformers/QueryBuilder.ts b/packages/core/src/transformers/QueryBuilder.ts index 7c29881..1ba26a4 100644 --- a/packages/core/src/transformers/QueryBuilder.ts +++ b/packages/core/src/transformers/QueryBuilder.ts @@ -1,6 +1,8 @@ -import { SetClause, SetClauseItem, FromClause, WhereClause, SelectClause, SelectItem, SourceAliasExpression, SourceExpression, SubQuerySource, WithClause, TableSource, UpdateClause, InsertClause, OrderByClause } from '../models/Clause'; +import { SetClause, SetClauseItem, FromClause, WhereClause, SelectClause, SelectItem, SourceAliasExpression, SourceExpression, SubQuerySource, WithClause, TableSource, UpdateClause, InsertClause, OrderByClause, DeleteClause, UsingClause } from '../models/Clause'; import { UpdateQuery } from '../models/UpdateQuery'; -import { BinaryExpression, ColumnReference } from '../models/ValueComponent'; +import { DeleteQuery } from '../models/DeleteQuery'; +import { MergeQuery, MergeWhenClause, MergeUpdateAction, MergeDeleteAction, MergeInsertAction, MergeDoNothingAction } from '../models/MergeQuery'; +import { BinaryExpression, ColumnReference, ValueList } from '../models/ValueComponent'; import { SelectValueCollector } from './SelectValueCollector'; import { BinarySelectQuery, SelectQuery, SimpleSelectQuery, ValuesQuery } from "../models/SelectQuery"; import { CTECollector } from "./CTECollector"; @@ -9,6 +11,8 @@ import { CreateTableQuery } from "../models/CreateTableQuery"; import { InsertQuery } from "../models/InsertQuery"; import { CTEDisabler } from './CTEDisabler'; import { SourceExpressionParser } from '../parsers/SourceExpressionParser'; +import type { InsertQueryConversionOptions, UpdateQueryConversionOptions, DeleteQueryConversionOptions, MergeQueryConversionOptions } from "../models/SelectQuery"; +import { InsertQuerySelectValuesConverter } from "./InsertQuerySelectValuesConverter"; /** * QueryBuilder provides static methods to build or convert various SQL query objects. @@ -219,93 +223,505 @@ export class QueryBuilder { } /** - * Converts a SELECT query to an INSERT query (INSERT INTO ... SELECT ...) - * @param selectQuery The SELECT query to use as the source - * @param tableName The name of the table to insert into - * @param columns Optional: array of column names. If omitted, columns are inferred from the selectQuery - * @returns An InsertQuery instance + * Converts a SELECT query to an INSERT query (INSERT INTO ... SELECT ...). */ - public static buildInsertQuery(selectQuery: SimpleSelectQuery, tableName: string): InsertQuery { - let cols: string[]; + public static buildInsertQuery(selectQuery: SimpleSelectQuery, targetOrOptions: string | InsertQueryConversionOptions, explicitColumns?: string[]): InsertQuery { + // Derive normalized options while preserving the legacy signature for backward compatibility. + const options = QueryBuilder.normalizeInsertOptions(targetOrOptions, explicitColumns); + // Determine the final column order either from user-provided options or by inferring from the select list. + const columnNames = QueryBuilder.prepareInsertColumns(selectQuery, options.columns ?? null); + // Promote WITH clauses to the INSERT statement so the SELECT body remains self-contained. + const withClause = QueryBuilder.extractWithClause(selectQuery); + + const sourceExpr = SourceExpressionParser.parse(options.target); + return new InsertQuery({ + withClause: withClause ?? undefined, + insertClause: new InsertClause(sourceExpr, columnNames), + selectQuery + }); + } - const count = selectQuery.selectClause.items.length; + /** + * Converts an INSERT ... VALUES query into INSERT ... SELECT form using UNION ALL. + * @param insertQuery The VALUES-based InsertQuery to convert. + * @returns A new InsertQuery that selects rows instead of using VALUES. + */ + public static convertInsertValuesToSelect(insertQuery: InsertQuery): InsertQuery { + return InsertQuerySelectValuesConverter.toSelectUnion(insertQuery); + } - // Try to infer columns from the selectQuery - const collector = new SelectValueCollector(); - const items = collector.collect(selectQuery); - cols = items.map(item => item.name); - if (!cols.length || count !== cols.length) { - throw new Error( - `Columns cannot be inferred from the selectQuery. ` + - `Make sure you are not using wildcards or unnamed columns.\n` + - `Select clause column count: ${count}, ` + - `Columns with valid names: ${cols.length}\n` + - `Detected column names: [${cols.join(", ")}]` - ); + /** + * Converts an INSERT ... SELECT (optionally with UNION ALL) into INSERT ... VALUES form. + * @param insertQuery The SELECT-based InsertQuery to convert. + * @returns A new InsertQuery that uses VALUES tuples. + */ + public static convertInsertSelectToValues(insertQuery: InsertQuery): InsertQuery { + return InsertQuerySelectValuesConverter.toValues(insertQuery); + } + + /** + * Builds an UPDATE query from a SELECT query and conversion options. + */ + public static buildUpdateQuery(selectQuery: SimpleSelectQuery, selectSourceOrOptions: string | UpdateQueryConversionOptions, updateTableExprRaw?: string, primaryKeys?: string | string[]): UpdateQuery { + // Normalize the function arguments into a single configuration object. + const options = QueryBuilder.normalizeUpdateOptions(selectSourceOrOptions, updateTableExprRaw, primaryKeys); + // Collect select-list metadata and align columns before mutating the query during WITH extraction. + const updateColumns = QueryBuilder.prepareUpdateColumns(selectQuery, options.primaryKeys, options.columns ?? null); + const updateClause = new UpdateClause(SourceExpressionParser.parse(options.target)); + const targetAlias = updateClause.getSourceAliasName(); + if (!targetAlias) { + throw new Error(`Source expression does not have an alias. Please provide an alias for the source expression.`); } - // Generate SourceExpression (supports only table name, does not support alias or schema) - const sourceExpr = SourceExpressionParser.parse(tableName); - return new InsertQuery({ - insertClause: new InsertClause(sourceExpr, cols), - selectQuery: selectQuery + // Move CTE definitions to the UPDATE statement for cleaner SQL. + const withClause = QueryBuilder.extractWithClause(selectQuery); + + const setItems = updateColumns.map(column => new SetClauseItem(column, QueryBuilder.toColumnReference(options.sourceAlias, column))); + if (setItems.length === 0) { + throw new Error(`No updatable columns found. Ensure the select list contains at least one column other than the specified primary keys.`); + } + const setClause = new SetClause(setItems); + + const fromClause = new FromClause(selectQuery.toSource(options.sourceAlias), null); + const whereClause = new WhereClause(QueryBuilder.buildEqualityPredicate(targetAlias, options.sourceAlias, options.primaryKeys)); + + return new UpdateQuery({ + updateClause, + setClause, + fromClause, + whereClause, + withClause: withClause ?? undefined }); } /** - * Builds an UPDATE query from a SELECT query, table name, and primary key(s). - * @param selectQuery The SELECT query providing new values (must select all columns to update and PKs) - * @param updateTableExprRaw The table name to update - * @param primaryKeys The primary key column name(s) - * @returns UpdateQuery instance + * Builds a DELETE query that deletes the rows matched by the SELECT query output. */ - public static buildUpdateQuery(selectQuery: SimpleSelectQuery, selectSourceName: string, updateTableExprRaw: string, primaryKeys: string | string[]) { - const updateClause = new UpdateClause(SourceExpressionParser.parse(updateTableExprRaw)); + public static buildDeleteQuery(selectQuery: SimpleSelectQuery, options: DeleteQueryConversionOptions): DeleteQuery { + // Normalise options to guarantee arrays and alias defaults. + const normalized = QueryBuilder.normalizeDeleteOptions(options); + const predicateColumns = QueryBuilder.prepareDeleteColumns(selectQuery, normalized.primaryKeys, normalized.columns ?? null); + const deleteClause = new DeleteClause(SourceExpressionParser.parse(normalized.target)); + const targetAlias = deleteClause.getSourceAliasName(); + if (!targetAlias) { + throw new Error(`Source expression does not have an alias. Please provide an alias for the delete target.`); + } - const pkArray = Array.isArray(primaryKeys) ? primaryKeys : [primaryKeys]; - const selectCollector = new SelectValueCollector(); - const selectItems = selectCollector.collect(selectQuery); + const withClause = QueryBuilder.extractWithClause(selectQuery); - const cteCollector = new CTECollector(); - const collectedCTEs = cteCollector.collect(selectQuery); - const cteDisabler = new CTEDisabler(); - cteDisabler.execute(selectQuery); + const usingClause = new UsingClause([selectQuery.toSource(normalized.sourceAlias)]); + const whereClause = new WhereClause(QueryBuilder.buildEqualityPredicate(targetAlias, normalized.sourceAlias, predicateColumns)); + + return new DeleteQuery({ + deleteClause, + usingClause, + whereClause, + withClause: withClause ?? undefined + }); + } + + /** + * Builds a MERGE query (upsert) that coordinates actions based on row matches. + */ + public static buildMergeQuery(selectQuery: SimpleSelectQuery, options: MergeQueryConversionOptions): MergeQuery { + // Ensure the configuration is fully expanded before inspection. + const normalized = QueryBuilder.normalizeMergeOptions(options); + const mergeColumnPlan = QueryBuilder.prepareMergeColumns( + selectQuery, + normalized.primaryKeys, + normalized.updateColumns ?? null, + normalized.insertColumns ?? null, + normalized.matchedAction ?? 'update', + normalized.notMatchedAction ?? 'insert' + ); + + const targetExpression = SourceExpressionParser.parse(normalized.target); + const targetAlias = targetExpression.getAliasName(); + if (!targetAlias) { + throw new Error(`Source expression does not have an alias. Please provide an alias for the merge target.`); + } + + const withClause = QueryBuilder.extractWithClause(selectQuery); + + const onCondition = QueryBuilder.buildEqualityPredicate(targetAlias, normalized.sourceAlias, normalized.primaryKeys); + const sourceExpression = selectQuery.toSource(normalized.sourceAlias); + + const whenClauses: MergeWhenClause[] = []; + + const matchedAction = normalized.matchedAction ?? 'update'; + if (matchedAction === 'update') { + if (mergeColumnPlan.updateColumns.length === 0) { + throw new Error(`No columns available for MERGE update action. Provide updateColumns or ensure the select list includes non-key columns.`); + } + const setItems = mergeColumnPlan.updateColumns.map(column => new SetClauseItem(column, QueryBuilder.toColumnReference(normalized.sourceAlias, column))); + whenClauses.push(new MergeWhenClause("matched", new MergeUpdateAction(new SetClause(setItems)))); + } else if (matchedAction === 'delete') { + whenClauses.push(new MergeWhenClause("matched", new MergeDeleteAction())); + } else if (matchedAction === 'doNothing') { + whenClauses.push(new MergeWhenClause("matched", new MergeDoNothingAction())); + } + + const notMatchedAction = normalized.notMatchedAction ?? 'insert'; + if (notMatchedAction === 'insert') { + if (mergeColumnPlan.insertColumns.length === 0) { + throw new Error('Unable to infer MERGE insert columns. Provide insertColumns explicitly.'); + } + const insertValues = new ValueList(mergeColumnPlan.insertColumns.map(column => QueryBuilder.toColumnReference(normalized.sourceAlias, column))); + whenClauses.push(new MergeWhenClause("not_matched", new MergeInsertAction({ + columns: mergeColumnPlan.insertColumns, + values: insertValues + }))); + } else if (notMatchedAction === 'doNothing') { + whenClauses.push(new MergeWhenClause("not_matched", new MergeDoNothingAction())); + } + + const notMatchedBySourceAction = normalized.notMatchedBySourceAction ?? 'doNothing'; + if (notMatchedBySourceAction === 'delete') { + whenClauses.push(new MergeWhenClause("not_matched_by_source", new MergeDeleteAction())); + } else if (notMatchedBySourceAction === 'doNothing') { + whenClauses.push(new MergeWhenClause("not_matched_by_source", new MergeDoNothingAction())); + } + + if (whenClauses.length === 0) { + throw new Error(`At least one MERGE action must be generated. Adjust the merge conversion options.`); + } + + return new MergeQuery({ + withClause: withClause ?? undefined, + target: targetExpression, + source: sourceExpression, + onCondition, + whenClauses + }); + } + + private static normalizeInsertOptions(targetOrOptions: string | InsertQueryConversionOptions, explicitColumns?: string[]): InsertQueryConversionOptions { + if (typeof targetOrOptions === 'string') { + return { + target: targetOrOptions, + columns: explicitColumns + }; + } + if (explicitColumns && explicitColumns.length > 0) { + return { + ...targetOrOptions, + columns: explicitColumns + }; + } + return { ...targetOrOptions }; + } + + private static normalizeUpdateOptions(selectSourceOrOptions: string | UpdateQueryConversionOptions, updateTableExprRaw?: string, primaryKeys?: string | string[]): { target: string; primaryKeys: string[]; sourceAlias: string; columns?: string[] } { + if (typeof selectSourceOrOptions === 'string') { + if (!updateTableExprRaw) { + throw new Error('updateTableExprRaw is required when using the legacy buildUpdateQuery signature.'); + } + if (primaryKeys === undefined) { + throw new Error('primaryKeys are required when using the legacy buildUpdateQuery signature.'); + } + return { + target: updateTableExprRaw, + primaryKeys: QueryBuilder.normalizeColumnArray(primaryKeys), + sourceAlias: selectSourceOrOptions + }; + } + + return { + target: selectSourceOrOptions.target, + primaryKeys: QueryBuilder.normalizeColumnArray(selectSourceOrOptions.primaryKeys), + sourceAlias: selectSourceOrOptions.sourceAlias ?? 'src', + columns: selectSourceOrOptions.columns + }; + } + + private static normalizeDeleteOptions(options: DeleteQueryConversionOptions): DeleteQueryConversionOptions & { primaryKeys: string[]; sourceAlias: string } { + return { + ...options, + primaryKeys: QueryBuilder.normalizeColumnArray(options.primaryKeys), + sourceAlias: options.sourceAlias ?? 'src' + }; + } + + private static normalizeMergeOptions(options: MergeQueryConversionOptions): MergeQueryConversionOptions & { primaryKeys: string[]; sourceAlias: string } { + return { + ...options, + primaryKeys: QueryBuilder.normalizeColumnArray(options.primaryKeys), + sourceAlias: options.sourceAlias ?? 'src' + }; + } + + private static normalizeColumnArray(columns: string | string[]): string[] { + const array = Array.isArray(columns) ? columns : [columns]; + const normalized = array.map(col => col.trim()).filter(col => col.length > 0); + if (!normalized.length) { + throw new Error('At least one column must be specified.'); + } + return normalized; + } + + private static collectSelectItems(selectQuery: SimpleSelectQuery) { + const collector = new SelectValueCollector(); + return collector.collect(selectQuery); + } - for (const pk of pkArray) { - if (!selectItems.some(item => item.name === pk)) { + private static collectSelectColumnNames(selectQuery: SimpleSelectQuery): string[] { + const items = QueryBuilder.collectSelectItems(selectQuery); + const names: string[] = []; + for (const item of items) { + if (!item.name || item.name === '*') { + throw new Error( + `Columns cannot be inferred from the selectQuery. ` + + `Make sure you are not using wildcards or unnamed columns.` + ); + } + if (!names.includes(item.name)) { + names.push(item.name); + } + } + if (!names.length) { + throw new Error('Unable to determine any column names from selectQuery.'); + } + return names; + } + + private static ensurePrimaryKeys(selectColumns: string[], primaryKeys: string[]): void { + const available = new Set(selectColumns); + for (const pk of primaryKeys) { + if (!available.has(pk)) { throw new Error(`Primary key column '${pk}' is not present in selectQuery select list.`); } } + } - const updateSourceName = updateClause.getSourceAliasName(); - if (!updateSourceName) { - throw new Error(`Source expression does not have an alias. Please provide an alias for the source expression.`); + private static prepareInsertColumns(selectQuery: SimpleSelectQuery, optionColumns: string[] | null): string[] { + const selectColumns = QueryBuilder.collectSelectColumnNames(selectQuery); + if (optionColumns && optionColumns.length > 0) { + const normalized = QueryBuilder.normalizeColumnArray(optionColumns); + const uniqueNormalized = normalized.filter((name, idx) => normalized.indexOf(name) === idx); + const missing = uniqueNormalized.filter(name => !selectColumns.includes(name)); + if (missing.length > 0) { + throw new Error(`Columns specified in conversion options were not found in selectQuery select list: [${missing.join(', ')}].`); + } + QueryBuilder.rebuildSelectClause(selectQuery, uniqueNormalized); + QueryBuilder.ensureSelectClauseSize(selectQuery, uniqueNormalized.length); + return uniqueNormalized; } + QueryBuilder.ensureSelectClauseSize(selectQuery, selectColumns.length); + return selectColumns; + } - const setColumns = selectItems.filter(item => !pkArray.includes(item.name)); - const setItems = setColumns.map(col => new SetClauseItem(col.name, new ColumnReference(updateSourceName, col.name))); - const setClause = new SetClause(setItems); + private static prepareUpdateColumns(selectQuery: SimpleSelectQuery, primaryKeys: string[], explicitColumns: string[] | null): string[] { + const selectColumns = QueryBuilder.collectSelectColumnNames(selectQuery); + QueryBuilder.ensurePrimaryKeys(selectColumns, primaryKeys); + + const primaryKeySet = new Set(primaryKeys); + const updateCandidates = selectColumns.filter(name => !primaryKeySet.has(name)); + + let updateColumnsOrdered: string[]; + if (explicitColumns && explicitColumns.length > 0) { + const normalized = QueryBuilder.normalizeColumnArray(explicitColumns); + const uniqueNormalized = normalized.filter((name, idx) => normalized.indexOf(name) === idx); + const missing = uniqueNormalized.filter(name => primaryKeySet.has(name) || !updateCandidates.includes(name)); + if (missing.length > 0) { + throw new Error(`Provided update columns were not found in selectQuery output or are primary keys: [${missing.join(', ')}].`); + } + updateColumnsOrdered = uniqueNormalized; + } else { + updateColumnsOrdered = Array.from(new Set(updateCandidates)); + } + + const desiredOrder = Array.from(new Set([ + ...primaryKeys, + ...updateColumnsOrdered + ])); + QueryBuilder.rebuildSelectClause(selectQuery, desiredOrder); + QueryBuilder.ensureSelectClauseSize(selectQuery, desiredOrder.length); + + return updateColumnsOrdered; + } + + private static prepareDeleteColumns(selectQuery: SimpleSelectQuery, primaryKeys: string[], explicitColumns: string[] | null): string[] { + const selectColumns = QueryBuilder.collectSelectColumnNames(selectQuery); + QueryBuilder.ensurePrimaryKeys(selectColumns, primaryKeys); + + const primaryKeySet = new Set(primaryKeys); + let matchColumns: string[] = []; + if (explicitColumns && explicitColumns.length > 0) { + const normalized = QueryBuilder.normalizeColumnArray(explicitColumns); + const preferred = new Set(normalized); + matchColumns = selectColumns.filter(name => preferred.has(name) && !primaryKeySet.has(name)); + } + + const requiredColumns = new Set(); + primaryKeys.forEach(key => requiredColumns.add(key)); + matchColumns.forEach(col => requiredColumns.add(col)); + + const desiredOrder = selectColumns.filter(name => requiredColumns.has(name)); + QueryBuilder.rebuildSelectClause(selectQuery, desiredOrder); + QueryBuilder.ensureSelectClauseSize(selectQuery, desiredOrder.length); + + return desiredOrder; + } + + private static prepareMergeColumns( + selectQuery: SimpleSelectQuery, + primaryKeys: string[], + explicitUpdateColumns: string[] | null, + explicitInsertColumns: string[] | null, + matchedAction: string, + notMatchedAction: string + ): { updateColumns: string[]; insertColumns: string[] } { + const selectColumns = QueryBuilder.collectSelectColumnNames(selectQuery); + QueryBuilder.ensurePrimaryKeys(selectColumns, primaryKeys); + + const primaryKeySet = new Set(primaryKeys); + + let updateColumnsOrdered: string[] = []; + if (matchedAction === 'update') { + const candidates = selectColumns.filter(name => !primaryKeySet.has(name)); + if (explicitUpdateColumns && explicitUpdateColumns.length > 0) { + const normalized = QueryBuilder.normalizeColumnArray(explicitUpdateColumns); + const uniqueNormalized = normalized.filter((name, idx) => normalized.indexOf(name) === idx); + const missing = uniqueNormalized.filter(name => primaryKeySet.has(name) || !candidates.includes(name)); + if (missing.length > 0) { + throw new Error(`Provided update columns were not found in selectQuery output or are primary keys: [${missing.join(', ')}].`); + } + updateColumnsOrdered = uniqueNormalized; + } else { + updateColumnsOrdered = Array.from(new Set(candidates)); + } + } + + let insertColumnsOrdered: string[] = []; + if (notMatchedAction === 'insert') { + if (explicitInsertColumns && explicitInsertColumns.length > 0) { + const normalized = QueryBuilder.normalizeColumnArray(explicitInsertColumns); + const uniqueNormalized = normalized.filter((name, idx) => normalized.indexOf(name) === idx); + const missing = uniqueNormalized.filter(name => !selectColumns.includes(name)); + if (missing.length > 0) { + throw new Error(`Provided insert columns were not found in selectQuery output: [${missing.join(', ')}].`); + } + insertColumnsOrdered = uniqueNormalized; + } else { + insertColumnsOrdered = Array.from(new Set(selectColumns)); + } + } + + const desiredOrder = Array.from(new Set([ + ...primaryKeys, + ...updateColumnsOrdered, + ...insertColumnsOrdered, + ...selectColumns + ])).filter(name => selectColumns.includes(name)); + QueryBuilder.rebuildSelectClause(selectQuery, desiredOrder); + QueryBuilder.ensureSelectClauseSize(selectQuery, desiredOrder.length); + + const finalUpdateColumns = matchedAction === 'update' + ? updateColumnsOrdered + : []; + const finalInsertColumns = notMatchedAction === 'insert' + ? insertColumnsOrdered + : []; + + return { + updateColumns: finalUpdateColumns, + insertColumns: finalInsertColumns + }; + } + + private static rebuildSelectClause(selectQuery: SimpleSelectQuery, desiredColumns: string[]): void { + const itemMap = new Map(); + for (const item of selectQuery.selectClause.items) { + const name = QueryBuilder.getSelectItemName(item); + if (!name) { + continue; + } + if (!itemMap.has(name)) { + itemMap.set(name, item); + } + } + + const rebuiltItems: SelectItem[] = []; + const seen = new Set(); + for (const column of desiredColumns) { + if (seen.has(column)) { + continue; + } + const item = itemMap.get(column); + if (!item) { + throw new Error(`Column '${column}' not found in select clause.`); + } + rebuiltItems.push(item); + seen.add(column); + } + + if (!rebuiltItems.length) { + throw new Error('Unable to rebuild select clause with the requested columns.'); + } - const from = new FromClause(selectQuery.toSource(selectSourceName), null); + selectQuery.selectClause.items = rebuiltItems; + } - let where: BinaryExpression | null = null; - for (const pk of pkArray) { - const cond = new BinaryExpression( - new ColumnReference(updateSourceName, pk), + private static getSelectItemName(item: SelectItem): string | null { + if (item.identifier) { + return item.identifier.name; + } + if (item.value instanceof ColumnReference) { + return item.value.column.name; + } + return null; + } + + private static ensureSelectClauseSize(selectQuery: SimpleSelectQuery, expected: number): void { + if (selectQuery.selectClause.items.length !== expected) { + throw new Error( + `Select clause column count (${selectQuery.selectClause.items.length}) does not match expected count (${expected}).` + ); + } + } + + private static extractWithClause(selectQuery: SimpleSelectQuery): WithClause | null { + const cteCollector = new CTECollector(); + const collected = cteCollector.collect(selectQuery); + if (collected.length === 0) { + return null; + } + const cteDisabler = new CTEDisabler(); + cteDisabler.execute(selectQuery); + return new WithClause(false, collected); + } + + private static buildEqualityPredicate(leftAlias: string, rightAlias: string, columns: string[]): BinaryExpression { + const uniqueColumns = QueryBuilder.mergeUniqueColumns(columns); + if (!uniqueColumns.length) { + throw new Error('At least one column is required to build a comparison predicate.'); + } + + let predicate: BinaryExpression | null = null; + for (const column of uniqueColumns) { + const comparison = new BinaryExpression( + QueryBuilder.toColumnReference(leftAlias, column), '=', - new ColumnReference(selectSourceName, pk) + QueryBuilder.toColumnReference(rightAlias, column) ); - where = where ? new BinaryExpression(where, 'and', cond) : cond; + predicate = predicate ? new BinaryExpression(predicate, 'and', comparison) : comparison; } - const whereClause = new WhereClause(where!); + return predicate!; + } - const updateQuery = new UpdateQuery({ - updateClause: updateClause, - setClause: setClause, - fromClause: from, - whereClause: whereClause, - withClause: collectedCTEs.length > 0 ? new WithClause(false, collectedCTEs) : undefined, - }); - return updateQuery; + private static toColumnReference(alias: string, column: string): ColumnReference { + return new ColumnReference(alias, column); + } + + private static mergeUniqueColumns(columns: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const column of columns) { + if (!seen.has(column)) { + seen.add(column); + result.push(column); + } + } + return result; } } diff --git a/packages/core/tests/transformers/InsertQuerySelectValuesConverter.test.ts b/packages/core/tests/transformers/InsertQuerySelectValuesConverter.test.ts new file mode 100644 index 0000000..2837ced --- /dev/null +++ b/packages/core/tests/transformers/InsertQuerySelectValuesConverter.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { InsertQueryParser } from '../../src/parsers/InsertQueryParser'; +import { SqlFormatter } from '../../src/transformers/SqlFormatter'; +import { QueryBuilder } from '../../src/transformers/QueryBuilder'; +import { InsertQuerySelectValuesConverter } from '../../src/transformers/InsertQuerySelectValuesConverter'; + +const formatter = () => new SqlFormatter(); + +describe('InsertQuerySelectValuesConverter', () => { + const valuesSql = `INSERT INTO sale (sale_date, price, created_at) VALUES + ('2023-01-01',160,'2024-01-11 14:29:01.618'), + ('2023-03-12',200,'2024-01-11 14:29:01.618')`; + + const unionSql = `INSERT INTO sale(sale_date, price, created_at) +SELECT '2023-01-01' AS sale_date, 160 AS price, '2024-01-11 14:29:01.618' AS created_at +UNION ALL +SELECT '2023-03-12' AS sale_date, 200 AS price, '2024-01-11 14:29:01.618' AS created_at`; + + it('converts VALUES form to SELECT UNION ALL form', () => { + const insert = InsertQueryParser.parse(valuesSql); + const converted = InsertQuerySelectValuesConverter.toSelectUnion(insert); + const sql = formatter().format(converted).formattedSql; + + expect(sql).toBe( + "insert into \"sale\"(\"sale_date\", \"price\", \"created_at\") select '2023-01-01' as \"sale_date\", 160 as \"price\", '2024-01-11 14:29:01.618' as \"created_at\" union all select '2023-03-12' as \"sale_date\", 200 as \"price\", '2024-01-11 14:29:01.618' as \"created_at\"" + ); + }); + + it('converts SELECT UNION ALL form back to VALUES form', () => { + const insert = InsertQueryParser.parse(unionSql); + const converted = InsertQuerySelectValuesConverter.toValues(insert); + const sql = formatter().format(converted).formattedSql; + + expect(sql).toBe( + "insert into \"sale\"(\"sale_date\", \"price\", \"created_at\") values ('2023-01-01', 160, '2024-01-11 14:29:01.618'), ('2023-03-12', 200, '2024-01-11 14:29:01.618')" + ); + }); + + it('round-trips VALUES -> SELECT -> VALUES without loss', () => { + const insert = InsertQueryParser.parse(valuesSql); + const toSelect = InsertQuerySelectValuesConverter.toSelectUnion(insert); + const roundTrip = InsertQuerySelectValuesConverter.toValues(toSelect); + const sql = formatter().format(roundTrip).formattedSql; + + expect(sql).toBe( + "insert into \"sale\"(\"sale_date\", \"price\", \"created_at\") values ('2023-01-01', 160, '2024-01-11 14:29:01.618'), ('2023-03-12', 200, '2024-01-11 14:29:01.618')" + ); + }); + + it('throws when converting VALUES without explicit column list', () => { + const insert = InsertQueryParser.parse("INSERT INTO sale VALUES ('2023-01-01')"); + expect(() => InsertQuerySelectValuesConverter.toSelectUnion(insert)).toThrowError( + "Cannot convert to SELECT form without explicit column list." + ); + }); + + it('throws when SELECT items lack required aliases during conversion to VALUES', () => { + const insert = InsertQueryParser.parse(`INSERT INTO sale(sale_date, price) SELECT '2023-01-01', 160`); + expect(() => InsertQuerySelectValuesConverter.toValues(insert)).toThrowError( + "Each SELECT item must have an alias matching target columns." + ); + }); + + it('throws when converting SELECT queries with FROM clause to VALUES form', () => { + const insert = InsertQueryParser.parse( + `INSERT INTO sale(sale_date, price, created_at) +SELECT sale_date AS sale_date, price AS price, created_at AS created_at FROM sale_staging` + ); + expect(() => InsertQuerySelectValuesConverter.toValues(insert)).toThrowError( + 'SELECT queries with FROM or WHERE clauses cannot be converted to VALUES.' + ); + }); + + it('throws when VALUES tuples do not match column length during SELECT conversion', () => { + const insert = InsertQueryParser.parse( + `INSERT INTO sale(sale_date, price) VALUES ('2023-01-01', 160, '2024-01-11 14:29:01.618')` + ); + expect(() => InsertQuerySelectValuesConverter.toSelectUnion(insert)).toThrowError( + 'Tuple value count does not match column count.' + ); + }); + + it('is accessible through QueryBuilder for VALUES -> SELECT conversion', () => { + const insert = InsertQueryParser.parse(valuesSql); + const converted = QueryBuilder.convertInsertValuesToSelect(insert); + const sql = formatter().format(converted).formattedSql; + + expect(sql).toBe( + "insert into \"sale\"(\"sale_date\", \"price\", \"created_at\") select '2023-01-01' as \"sale_date\", 160 as \"price\", '2024-01-11 14:29:01.618' as \"created_at\" union all select '2023-03-12' as \"sale_date\", 200 as \"price\", '2024-01-11 14:29:01.618' as \"created_at\"" + ); + }); + + it('is accessible through QueryBuilder for SELECT -> VALUES conversion', () => { + const insert = InsertQueryParser.parse(unionSql); + const converted = QueryBuilder.convertInsertSelectToValues(insert); + const sql = formatter().format(converted).formattedSql; + + expect(sql).toBe( + "insert into \"sale\"(\"sale_date\", \"price\", \"created_at\") values ('2023-01-01', 160, '2024-01-11 14:29:01.618'), ('2023-03-12', 200, '2024-01-11 14:29:01.618')" + ); + }); +}); diff --git a/packages/core/tests/transformers/buildDeleteQuery.test.ts b/packages/core/tests/transformers/buildDeleteQuery.test.ts new file mode 100644 index 0000000..aae099a --- /dev/null +++ b/packages/core/tests/transformers/buildDeleteQuery.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { SelectQueryParser } from '../../src/parsers/SelectQueryParser'; +import { QueryBuilder } from '../../src/transformers/QueryBuilder'; +import { SqlFormatter } from '../../src/transformers/SqlFormatter'; +import { SimpleSelectQuery } from '../../src/models/SimpleSelectQuery'; + +describe('buildDeleteQuery', () => { + it('builds DELETE using QueryBuilder with primary key match', () => { + const select = SelectQueryParser.parse('SELECT id FROM users_staging WHERE flagged = true') as SimpleSelectQuery; + + const deleteQuery = QueryBuilder.buildDeleteQuery(select, { + target: 'users u', + primaryKeys: 'id', + sourceAlias: 'src' + }); + const sql = new SqlFormatter().format(deleteQuery).formattedSql; + + expect(sql).toBe('delete from "users" as "u" using (select "id" from "users_staging" where "flagged" = true) as "src" where "u"."id" = "src"."id"'); + }); + + it('builds DELETE via SelectQuery method with additional match columns', () => { + const select = SelectQueryParser.parse('SELECT id, tenant_id FROM users_staging') as SimpleSelectQuery; + + const deleteQuery = select.toDeleteQuery({ + target: 'users', + primaryKeys: 'id', + columns: ['tenant_id'], + sourceAlias: 'src' + }); + const sql = new SqlFormatter().format(deleteQuery).formattedSql; + + expect(sql).toBe('delete from "users" using (select "id", "tenant_id" from "users_staging") as "src" where "users"."id" = "src"."id" and "users"."tenant_id" = "src"."tenant_id"'); + }); + + it('hoists CTEs when building DELETE queries', () => { + const select = SelectQueryParser.parse( + 'WITH flagged AS (SELECT id FROM users_staging WHERE flagged = true) SELECT id FROM flagged' + ) as SimpleSelectQuery; + + const deleteQuery = QueryBuilder.buildDeleteQuery(select, { + target: 'users', + primaryKeys: 'id' + }); + const sql = new SqlFormatter().format(deleteQuery).formattedSql; + + expect(sql).toBe( + 'with "flagged" as (select "id" from "users_staging" where "flagged" = true) delete from "users" using (select "id" from "flagged") as "src" where "users"."id" = "src"."id"' + ); + }); + + it('uses default source alias when none is provided', () => { + const select = SelectQueryParser.parse('SELECT id FROM users_staging') as SimpleSelectQuery; + + const deleteQuery = QueryBuilder.buildDeleteQuery(select, { + target: 'users', + primaryKeys: 'id' + }); + const sql = new SqlFormatter().format(deleteQuery).formattedSql; + + expect(sql).toBe('delete from "users" using (select "id" from "users_staging") as "src" where "users"."id" = "src"."id"'); + }); + + it('deduplicates composite primary keys when building predicates', () => { + const select = SelectQueryParser.parse('SELECT id, tenant_id FROM users_staging') as SimpleSelectQuery; + + const deleteQuery = QueryBuilder.buildDeleteQuery(select, { + target: 'users', + primaryKeys: ['id', 'tenant_id', 'id'], + columns: ['tenant_id', 'status'], + sourceAlias: 'alias_src' + }); + const sql = new SqlFormatter().format(deleteQuery).formattedSql; + + expect(sql).toBe( + 'delete from "users" using (select "id", "tenant_id" from "users_staging") as "alias_src" where "users"."id" = "alias_src"."id" and "users"."tenant_id" = "alias_src"."tenant_id"' + ); + }); +}); diff --git a/packages/core/tests/transformers/buildInsertQuery.test.ts b/packages/core/tests/transformers/buildInsertQuery.test.ts new file mode 100644 index 0000000..4662615 --- /dev/null +++ b/packages/core/tests/transformers/buildInsertQuery.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { SelectQueryParser } from '../../src/parsers/SelectQueryParser'; +import { QueryBuilder } from '../../src/transformers/QueryBuilder'; +import { SqlFormatter } from '../../src/transformers/SqlFormatter'; +import { SimpleSelectQuery } from '../../src/models/SimpleSelectQuery'; + +describe('buildInsertQuery', () => { + it('builds INSERT via QueryBuilder with options and hoists CTEs', () => { + const select = SelectQueryParser.parse( + 'WITH src AS (SELECT id, name FROM users_staging) SELECT id, name FROM src' + ) as SimpleSelectQuery; + + const insert = QueryBuilder.buildInsertQuery(select, { + target: 'users', + columns: ['id', 'name'] + }); + const sql = new SqlFormatter().format(insert).formattedSql; + + expect(sql).toBe('with "src" as (select "id", "name" from "users_staging") insert into "users"("id", "name") select "id", "name" from "src"'); + }); + + it('builds INSERT via SelectQuery method and infers columns', () => { + const select = SelectQueryParser.parse('SELECT id, email FROM accounts_backup') as SimpleSelectQuery; + + const insert = select.toInsertQuery({ target: 'accounts' }); + const sql = new SqlFormatter().format(insert).formattedSql; + + expect(sql).toBe('insert into "accounts"("id", "email") select "id", "email" from "accounts_backup"'); + }); + + it('throws when explicit columns reference missing select columns', () => { + const select = SelectQueryParser.parse('SELECT 1 AS id') as SimpleSelectQuery; + + expect(() => QueryBuilder.buildInsertQuery(select, { + target: 'users', + columns: ['id', 'name'] + })).toThrowError('Columns specified in conversion options were not found in selectQuery select list: [name].'); + }); + + it('filters the select clause to the provided columns', () => { + const select = SelectQueryParser.parse("SELECT 1 AS id, 'a' AS name, 2 AS value") as SimpleSelectQuery; + + const insert = QueryBuilder.buildInsertQuery(select, { + target: 'users', + columns: ['name', 'id'] + }); + const sql = new SqlFormatter().format(insert).formattedSql; + + expect(sql).toBe("insert into \"users\"(\"name\", \"id\") select 'a' as \"name\", 1 as \"id\""); + }); + + it('throws when select output has no overlap with explicit columns', () => { + const select = SelectQueryParser.parse('SELECT 2 AS value') as SimpleSelectQuery; + + expect(() => QueryBuilder.buildInsertQuery(select, { + target: 'users', + columns: ['id', 'name'] + })).toThrowError('Columns specified in conversion options were not found in selectQuery select list: [id, name].'); + }); + + it('throws when select output uses wildcard columns', () => { + const select = SelectQueryParser.parse('SELECT 1') as SimpleSelectQuery; + + expect(() => QueryBuilder.buildInsertQuery(select, { + target: 'users', + columns: ['id'] + })).toThrowError('Unable to determine any column names from selectQuery.'); + }); + + it('reorders select output to match explicit column order', () => { + const select = SelectQueryParser.parse('SELECT id, name FROM users_src') as SimpleSelectQuery; + + const insert = QueryBuilder.buildInsertQuery(select, { + target: 'users', + columns: ['name', 'id'] + }); + const sql = new SqlFormatter().format(insert).formattedSql; + + expect(sql).toBe('insert into "users"("name", "id") select "name", "id" from "users_src"'); + }); +}); diff --git a/packages/core/tests/transformers/buildMergeQuery.test.ts b/packages/core/tests/transformers/buildMergeQuery.test.ts new file mode 100644 index 0000000..8386143 --- /dev/null +++ b/packages/core/tests/transformers/buildMergeQuery.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { SelectQueryParser } from '../../src/parsers/SelectQueryParser'; +import { QueryBuilder } from '../../src/transformers/QueryBuilder'; +import { SqlFormatter } from '../../src/transformers/SqlFormatter'; +import { SimpleSelectQuery } from '../../src/models/SimpleSelectQuery'; +import { MergeDeleteAction, MergeDoNothingAction, MergeInsertAction, MergeUpdateAction } from '../../src/models/MergeQuery'; +import { ColumnReference, ValueList } from '../../src/models/ValueComponent'; + +describe('buildMergeQuery', () => { + it('builds MERGE with default actions', () => { + const select = SelectQueryParser.parse('SELECT id, name FROM incoming_users') as SimpleSelectQuery; + + const mergeQuery = QueryBuilder.buildMergeQuery(select, { + target: 'users u', + primaryKeys: 'id', + sourceAlias: 'src' + }); + + expect(mergeQuery.target.getAliasName()).toBe('u'); + expect(mergeQuery.source.getAliasName()).toBe('src'); + + const clauseTypes = mergeQuery.whenClauses.map(clause => clause.matchType); + expect(clauseTypes).toEqual(['matched', 'not_matched', 'not_matched_by_source']); + + const matchedAction = mergeQuery.whenClauses[0].action as MergeUpdateAction; + expect(matchedAction).toBeInstanceOf(MergeUpdateAction); + expect(matchedAction.setClause.items).toHaveLength(1); + const setValue = matchedAction.setClause.items[0].value as ColumnReference; + expect(setValue.namespaces?.[0].name).toBe('src'); + expect(setValue.column.name).toBe('name'); + + const insertAction = mergeQuery.whenClauses[1].action as MergeInsertAction; + expect(insertAction).toBeInstanceOf(MergeInsertAction); + expect(insertAction.columns?.map(col => col.name)).toEqual(['id', 'name']); + const insertValues = insertAction.values as ValueList; + expect(insertValues.values).toHaveLength(2); + const insertValueNamespaces = insertValues.values.map(value => (value as ColumnReference).namespaces?.[0].name); + expect(insertValueNamespaces).toEqual(['src', 'src']); + + const doNothingAction = mergeQuery.whenClauses[2].action; + expect(doNothingAction).toBeInstanceOf(MergeDoNothingAction); + }); + + it('builds MERGE via SelectQuery method with custom actions', () => { + const select = SelectQueryParser.parse('SELECT id, status FROM audit_events') as SimpleSelectQuery; + + const mergeQuery = select.toMergeQuery({ + target: 'events e', + primaryKeys: 'id', + matchedAction: 'delete', + notMatchedAction: 'doNothing', + notMatchedBySourceAction: 'delete', + sourceAlias: 'src' + }); + + expect(mergeQuery.target.getAliasName()).toBe('e'); + expect(mergeQuery.source.getAliasName()).toBe('src'); + + const clauseTypes = mergeQuery.whenClauses.map(clause => clause.matchType); + expect(clauseTypes).toEqual(['matched', 'not_matched', 'not_matched_by_source']); + + expect(mergeQuery.whenClauses[0].action).toBeInstanceOf(MergeDeleteAction); + expect(mergeQuery.whenClauses[1].action).toBeInstanceOf(MergeDoNothingAction); + expect(mergeQuery.whenClauses[2].action).toBeInstanceOf(MergeDeleteAction); + }); + + it('respects explicit merge column order when subsets are provided', () => { + const select = SelectQueryParser.parse('SELECT id, name FROM incoming_users') as SimpleSelectQuery; + + const mergeQuery = QueryBuilder.buildMergeQuery(select, { + target: 'users u', + primaryKeys: 'id', + updateColumns: ['name'], + insertColumns: ['name', 'id'], + sourceAlias: 'src' + }); + + const updateAction = mergeQuery.whenClauses.find(clause => clause.matchType === 'matched')?.action as MergeUpdateAction; + expect(updateAction.setClause.items.map(item => item.column.name)).toEqual(['name']); + + const insertAction = mergeQuery.whenClauses.find(clause => clause.matchType === 'not_matched')?.action as MergeInsertAction; + expect(insertAction.columns?.map(col => col.name)).toEqual(['name', 'id']); + }); + + it('removes extra select columns when explicit merge lists are provided', () => { + const select = SelectQueryParser.parse('SELECT id, name, age, extra FROM incoming_users') as SimpleSelectQuery; + + const mergeQuery = QueryBuilder.buildMergeQuery(select, { + target: 'users', + primaryKeys: 'id', + updateColumns: ['name', 'age'], + insertColumns: ['id', 'name'], + sourceAlias: 'src' + }); + + const updateAction = mergeQuery.whenClauses.find(clause => clause.matchType === 'matched')?.action as MergeUpdateAction; + expect(updateAction.setClause.items.map(item => item.column.name)).toEqual(['name', 'age']); + + const insertAction = mergeQuery.whenClauses.find(clause => clause.matchType === 'not_matched')?.action as MergeInsertAction; + expect(insertAction.columns?.map(col => col.name)).toEqual(['id', 'name']); + }); + + it('throws when merge update columns do not match select output', () => { + const select = SelectQueryParser.parse('SELECT id FROM incoming_users') as SimpleSelectQuery; + + expect(() => QueryBuilder.buildMergeQuery(select, { + target: 'users', + primaryKeys: 'id', + updateColumns: ['name'], + sourceAlias: 'src' + })).toThrowError('Provided update columns were not found in selectQuery output or are primary keys: [name].'); + }); + + it('throws when merge insert columns do not match select output', () => { + const select = SelectQueryParser.parse('SELECT id FROM incoming_users') as SimpleSelectQuery; + + expect(() => QueryBuilder.buildMergeQuery(select, { + target: 'users', + primaryKeys: 'id', + matchedAction: 'doNothing', + notMatchedAction: 'insert', + insertColumns: ['name'], + sourceAlias: 'src' + })).toThrowError('Provided insert columns were not found in selectQuery output: [name].'); + }); + + it('formats MERGE queries via SqlFormatter', () => { + const select = SelectQueryParser.parse('SELECT id, name FROM incoming_users') as SimpleSelectQuery; + + const mergeQuery = QueryBuilder.buildMergeQuery(select, { + target: 'users u', + primaryKeys: 'id', + updateColumns: ['name'], + insertColumns: ['id', 'name'], + sourceAlias: 'src' + }); + + const sql = new SqlFormatter().format(mergeQuery).formattedSql; + expect(sql).toBe('merge into "users" as "u" using (select "id", "name" from "incoming_users") as "src" on "u"."id" = "src"."id" when matched then update set "name" = "src"."name" when not matched then insert ("id", "name") values ("src"."id", "src"."name") when not matched by source then do nothing'); + }); +}); diff --git a/packages/core/tests/transformers/buildUpdateQuery.test.ts b/packages/core/tests/transformers/buildUpdateQuery.test.ts index 3bec4eb..50b031e 100644 --- a/packages/core/tests/transformers/buildUpdateQuery.test.ts +++ b/packages/core/tests/transformers/buildUpdateQuery.test.ts @@ -15,8 +15,7 @@ describe('buildUpdateQuery', () => { const sql = new SqlFormatter().format(update).formattedSql; // Assert - // Check that the WITH clause is correctly reflected in the UPDATE statement - expect(sql).toContain('with "active_users" as (select "id", "score" from "exam_results_new" where "active" = true) update "exam_results" set "score" = "exam_results"."score" from (select "id", "score" from "active_users") as "src" where "exam_results"."id" = "src"."id"'); + expect(sql).toBe('with "active_users" as (select "id", "score" from "exam_results_new" where "active" = true) update "exam_results" set "score" = "src"."score" from (select "id", "score" from "active_users") as "src" where "exam_results"."id" = "src"."id"'); }); it('generates UPDATE with table alias in updateTableExpr', () => { @@ -30,8 +29,7 @@ describe('buildUpdateQuery', () => { const sql = new SqlFormatter().format(update).formattedSql; // Assert - // Confirm that the UPDATE statement is generated correctly with alias - expect(sql).toContain('update "exam_results" as "er" set "score" = "er"."score" from (select "id", "score" from "exam_results_new") as "src" where "er"."id" = "src"."id"'); + expect(sql).toBe('update "exam_results" as "er" set "score" = "src"."score" from (select "id", "score" from "exam_results_new") as "src" where "er"."id" = "src"."id"'); }); it('generates simple UPDATE with single PK', () => { @@ -43,7 +41,7 @@ describe('buildUpdateQuery', () => { const sql = new SqlFormatter().format(update).formattedSql; // Assert - expect(sql).toContain('update "users" set "name" = "users"."name", "age" = "users"."age" from (select "id", "name", "age" from "users_new") as "src" where "users"."id" = "src"."id"'); + expect(sql).toBe('update "users" set "name" = "src"."name", "age" = "src"."age" from (select "id", "name", "age" from "users_new") as "src" where "users"."id" = "src"."id"'); }); it('generates UPDATE with composite PK (order_items/order_details)', () => { @@ -56,6 +54,78 @@ describe('buildUpdateQuery', () => { const sql = new SqlFormatter().format(update).formattedSql; // Assert - expect(sql).toContain('update "order_details" set "quantity" = "order_details"."quantity" from (select "order_id", "item_id", "quantity" from "order_items") as "src" where "order_details"."order_id" = "src"."order_id" and "order_details"."item_id" = "src"."item_id"'); + expect(sql).toBe('update "order_details" set "quantity" = "src"."quantity" from (select "order_id", "item_id", "quantity" from "order_items") as "src" where "order_details"."order_id" = "src"."order_id" and "order_details"."item_id" = "src"."item_id"'); + }); + + it('builds UPDATE via SelectQuery.toUpdateQuery with explicit columns', () => { + // Arrange + const select = SelectQueryParser.parse('SELECT id, status FROM events_draft') as SimpleSelectQuery; + + // Act + const update = select.toUpdateQuery({ + target: 'events e', + primaryKeys: 'id', + columns: ['status'], + sourceAlias: 'src' + }); + const sql = new SqlFormatter().format(update).formattedSql; + + // Assert + expect(sql).toBe('update "events" as "e" set "status" = "src"."status" from (select "id", "status" from "events_draft") as "src" where "e"."id" = "src"."id"'); + }); + + it('throws when explicit update columns reference missing select columns', () => { + const select = SelectQueryParser.parse('SELECT id, name FROM users_src') as SimpleSelectQuery; + + expect(() => QueryBuilder.buildUpdateQuery(select, { + target: 'users u', + primaryKeys: 'id', + columns: ['name', 'age'], + sourceAlias: 'src' + })).toThrowError('Provided update columns were not found in selectQuery output or are primary keys: [age].'); + }); + + it('removes extra select columns when explicit update list provided', () => { + const select = SelectQueryParser.parse('SELECT id, name, age, extra FROM users_src') as SimpleSelectQuery; + + const update = QueryBuilder.buildUpdateQuery(select, { + target: 'users', + primaryKeys: 'id', + columns: ['name', 'age'], + sourceAlias: 'src' + }); + const sql = new SqlFormatter().format(update).formattedSql; + + expect(sql).toBe('update "users" set "name" = "src"."name", "age" = "src"."age" from (select "id", "name", "age" from "users_src") as "src" where "users"."id" = "src"."id"'); + }); + + it('throws when explicit update columns do not match select output', () => { + const select = SelectQueryParser.parse('SELECT id FROM users_src') as SimpleSelectQuery; + + expect(() => QueryBuilder.buildUpdateQuery(select, { + target: 'users', + primaryKeys: 'id', + columns: ['name'], + sourceAlias: 'src' + })).toThrowError('Provided update columns were not found in selectQuery output or are primary keys: [name].'); + }); + + it('throws when select output omits required primary keys', () => { + const select = SelectQueryParser.parse('SELECT name FROM users_src') as SimpleSelectQuery; + + expect(() => QueryBuilder.buildUpdateQuery(select, { + target: 'users', + primaryKeys: 'id', + columns: ['name'], + sourceAlias: 'src' + })).toThrowError("Primary key column 'id' is not present in selectQuery select list."); + }); + + it('throws when select output only includes primary keys', () => { + const select = SelectQueryParser.parse('SELECT id FROM users_src') as SimpleSelectQuery; + + expect(() => QueryBuilder.buildUpdateQuery(select, "src", 'users', 'id')).toThrowError( + 'No updatable columns found. Ensure the select list contains at least one column other than the specified primary keys.' + ); }); });