From 6e06243632ffcbc3ed07a6e59d14fec06716151c Mon Sep 17 00:00:00 2001 From: MSugiura Date: Fri, 24 Oct 2025 22:15:16 +0900 Subject: [PATCH 1/2] feat: add cast style support to SqlPrintTokenParser and SqlFormatter; update DELETE query handling in QueryBuilder to use EXISTS syntax --- docs/guide/formatting-recipes.md | 17 +++++- .../core/src/parsers/SqlPrintTokenParser.ts | 30 +++++++++- .../core/src/transformers/QueryBuilder.ts | 17 ++++-- .../core/src/transformers/SqlFormatter.ts | 5 +- .../tests/parsers/SqlPrintTokenParser.test.ts | 2 +- .../parsers/ValueParser.array-slice.test.ts | 6 +- .../core/tests/parsers/ValueParser.test.ts | 60 +++++++++---------- .../tests/transformers/CTEDisabler.test.ts | 2 +- .../SqlFormatter.cast-style.test.ts | 19 ++++++ .../transformers/buildDeleteQuery.test.ts | 10 ++-- 10 files changed, 119 insertions(+), 49 deletions(-) create mode 100644 packages/core/tests/transformers/SqlFormatter.cast-style.test.ts diff --git a/docs/guide/formatting-recipes.md b/docs/guide/formatting-recipes.md index 30d10514..a2c41a8e 100644 --- a/docs/guide/formatting-recipes.md +++ b/docs/guide/formatting-recipes.md @@ -1,4 +1,4 @@ ---- +--- title: Formatting Recipes outline: deep --- @@ -36,6 +36,7 @@ const { formattedSql, params } = formatter.format(query); | `withClauseStyle` | `'standard'`, `'cte-oneline'`, `'full-oneline'` | `'standard'` | Expands or collapses common table expressions. | | `parenthesesOneLine`, `betweenOneLine`, `valuesOneLine`, `joinOneLine`, `caseOneLine`, `subqueryOneLine` | `true` / `false` | `false` for each | Opt-in switches that keep the corresponding construct on a single line even if other break settings would expand it. | | `exportComment` | `true` / `false` | `false` | Emits comments collected by the parser. Turn it on when you want annotations preserved. | +| `castStyle` | 'standard', 'postgres' | From preset or 'standard' | Chooses how CAST expressions are printed. 'standard' emits ANSI `CAST(expr AS type)` while 'postgres' emits `expr::type`. | Combine these settings to mirror house formatting conventions or align with existing lint rules. The following sections call out the options that trip up newcomers most often. @@ -87,6 +88,20 @@ Default behaviour (`'block'`) leaves comments exactly as they were parsed. Switc Use `valuesCommaBreak` when you need to keep the main query in trailing-comma style but prefer inline tuples inside a `VALUES` block (or vice versa). With `exportComment: true`, comments that appear before or after each tuple are preserved and printed alongside the formatted output, so inline annotations survive automated formatting. + +### Controlling CAST style + +`castStyle` lets you toggle between ANSI-compatible casts and PostgreSQL's shorthand. + +```typescript +new SqlFormatter().format(expr); // cast("price" as NUMERIC(10, 2)) +new SqlFormatter({ castStyle: 'postgres' }).format(expr); // "price"::NUMERIC(10, 2) +``` + +- Default (`'standard'`) keeps ANSI `CAST(... AS ...)`, which works across engines such as MySQL, SQL Server, DuckDB, and more. +- Set `castStyle: 'postgres'` when you explicitly target PostgreSQL-style `::` casts. Presets like `'postgres'`, `'redshift'`, and `'cockroachdb'` already switch this on. + +If you are migrating away from PostgreSQL-only syntax, enforce `castStyle: 'standard'` and phase out `::` usage gradually. ## Sample ```json diff --git a/packages/core/src/parsers/SqlPrintTokenParser.ts b/packages/core/src/parsers/SqlPrintTokenParser.ts index 08e51744..15657308 100644 --- a/packages/core/src/parsers/SqlPrintTokenParser.ts +++ b/packages/core/src/parsers/SqlPrintTokenParser.ts @@ -48,6 +48,8 @@ export enum ParameterStyle { Named = 'named' } +export type CastStyle = 'postgres' | 'standard'; + export interface FormatterConfig { identifierEscape?: { start: string; @@ -58,6 +60,8 @@ export interface FormatterConfig { * Parameter style: anonymous (?), indexed ($1), or named (:name) */ parameterStyle?: ParameterStyle; + /** Controls how CAST expressions are rendered */ + castStyle?: CastStyle; } export const PRESETS: Record = { @@ -70,11 +74,13 @@ export const PRESETS: Record = { identifierEscape: { start: '"', end: '"' }, parameterSymbol: '$', parameterStyle: ParameterStyle.Indexed, + castStyle: 'postgres', }, postgresWithNamedParams: { identifierEscape: { start: '"', end: '"' }, parameterSymbol: ':', parameterStyle: ParameterStyle.Named, + castStyle: 'postgres', }, sqlserver: { identifierEscape: { start: '[', end: ']' }, @@ -125,6 +131,7 @@ export const PRESETS: Record = { identifierEscape: { start: '"', end: '"' }, parameterSymbol: '$', parameterStyle: ParameterStyle.Indexed, + castStyle: 'postgres', }, athena: { identifierEscape: { start: '"', end: '"' }, @@ -150,6 +157,7 @@ export const PRESETS: Record = { identifierEscape: { start: '"', end: '"' }, parameterSymbol: '$', parameterStyle: ParameterStyle.Indexed, + castStyle: 'postgres', }, flinksql: { identifierEscape: { start: '`', end: '`' }, @@ -201,12 +209,14 @@ export class SqlPrintTokenParser implements SqlComponentVisitor { parameterDecorator: ParameterDecorator; identifierDecorator: IdentifierDecorator; index: number = 1; + private castStyle: CastStyle; constructor(options?: { preset?: FormatterConfig, identifierEscape?: { start: string; end: string }, parameterSymbol?: string | { start: string; end: string }, - parameterStyle?: 'anonymous' | 'indexed' | 'named' + parameterStyle?: 'anonymous' | 'indexed' | 'named', + castStyle?: CastStyle }) { if (options?.preset) { const preset = options.preset @@ -224,6 +234,8 @@ export class SqlPrintTokenParser implements SqlComponentVisitor { end: options?.identifierEscape?.end ?? '"' }); + this.castStyle = options?.castStyle ?? 'standard'; + this.handlers.set(ValueList.kind, (expr) => this.visitValueList(expr as ValueList)); this.handlers.set(ColumnReference.kind, (expr) => this.visitColumnReference(expr as ColumnReference)); this.handlers.set(QualifiedName.kind, (expr) => this.visitQualifiedName(expr as QualifiedName)); @@ -1310,9 +1322,23 @@ export class SqlPrintTokenParser implements SqlComponentVisitor { private visitCastExpression(arg: CastExpression): SqlPrintToken { const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.CastExpression); + // Use PostgreSQL-specific :: casts only when the preset explicitly opts in. + if (this.castStyle === 'postgres') { + token.innerTokens.push(this.visit(arg.input)); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.operator, '::')); + token.innerTokens.push(this.visit(arg.castType)); + return token; + } + + // Default to ANSI-compliant CAST(expression AS type) syntax for broader compatibility. + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'cast')); + token.innerTokens.push(SqlPrintTokenParser.PAREN_OPEN_TOKEN); token.innerTokens.push(this.visit(arg.input)); - token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.operator, '::')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'as')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); token.innerTokens.push(this.visit(arg.castType)); + token.innerTokens.push(SqlPrintTokenParser.PAREN_CLOSE_TOKEN); return token; } diff --git a/packages/core/src/transformers/QueryBuilder.ts b/packages/core/src/transformers/QueryBuilder.ts index 1ba26a4d..77a6c183 100644 --- a/packages/core/src/transformers/QueryBuilder.ts +++ b/packages/core/src/transformers/QueryBuilder.ts @@ -1,8 +1,8 @@ -import { SetClause, SetClauseItem, FromClause, WhereClause, SelectClause, SelectItem, SourceAliasExpression, SourceExpression, SubQuerySource, WithClause, TableSource, UpdateClause, InsertClause, OrderByClause, DeleteClause, UsingClause } from '../models/Clause'; +import { SetClause, SetClauseItem, FromClause, WhereClause, SelectClause, SelectItem, SourceAliasExpression, SourceExpression, SubQuerySource, WithClause, TableSource, UpdateClause, InsertClause, OrderByClause, DeleteClause } from '../models/Clause'; import { UpdateQuery } from '../models/UpdateQuery'; import { DeleteQuery } from '../models/DeleteQuery'; import { MergeQuery, MergeWhenClause, MergeUpdateAction, MergeDeleteAction, MergeInsertAction, MergeDoNothingAction } from '../models/MergeQuery'; -import { BinaryExpression, ColumnReference, ValueList } from '../models/ValueComponent'; +import { BinaryExpression, ColumnReference, InlineQuery, LiteralValue, UnaryExpression, ValueList } from '../models/ValueComponent'; import { SelectValueCollector } from './SelectValueCollector'; import { BinarySelectQuery, SelectQuery, SimpleSelectQuery, ValuesQuery } from "../models/SelectQuery"; import { CTECollector } from "./CTECollector"; @@ -309,12 +309,19 @@ export class QueryBuilder { const withClause = QueryBuilder.extractWithClause(selectQuery); - const usingClause = new UsingClause([selectQuery.toSource(normalized.sourceAlias)]); - const whereClause = new WhereClause(QueryBuilder.buildEqualityPredicate(targetAlias, normalized.sourceAlias, predicateColumns)); + // Build correlated EXISTS predicate instead of Postgres-specific USING clause. + const predicate = QueryBuilder.buildEqualityPredicate(targetAlias, normalized.sourceAlias, predicateColumns); + const sourceExpression = selectQuery.toSource(normalized.sourceAlias); + const existsSelectClause = new SelectClause([new SelectItem(new LiteralValue(1))]); + const existsSubquery = new SimpleSelectQuery({ + selectClause: existsSelectClause, + fromClause: new FromClause(sourceExpression, null), + whereClause: new WhereClause(predicate) + }); + const whereClause = new WhereClause(new UnaryExpression('exists', new InlineQuery(existsSubquery))); return new DeleteQuery({ deleteClause, - usingClause, whereClause, withClause: withClause ?? undefined }); diff --git a/packages/core/src/transformers/SqlFormatter.ts b/packages/core/src/transformers/SqlFormatter.ts index dfd78ecc..23b76cf1 100644 --- a/packages/core/src/transformers/SqlFormatter.ts +++ b/packages/core/src/transformers/SqlFormatter.ts @@ -1,4 +1,4 @@ -import { SqlPrintTokenParser, FormatterConfig, PRESETS } from '../parsers/SqlPrintTokenParser'; +import { SqlPrintTokenParser, FormatterConfig, PRESETS, CastStyle } from '../parsers/SqlPrintTokenParser'; import { SqlPrinter, CommaBreakStyle, AndBreakStyle, OrBreakStyle } from './SqlPrinter'; import { IndentCharOption, NewlineOption } from './LinePrinter'; // Import types for compatibility import { IdentifierEscapeOption, resolveIdentifierEscapeOption } from './FormatOptionResolver'; @@ -96,6 +96,8 @@ export interface SqlFormatterOptions extends BaseFormattingOptions { parameterSymbol?: string | { start: string; end: string }; /** Style for parameter formatting */ parameterStyle?: 'anonymous' | 'indexed' | 'named'; + /** Preferred CAST rendering style */ + castStyle?: CastStyle; } /** @@ -129,6 +131,7 @@ export class SqlFormatter { identifierEscape: resolvedIdentifierEscape ?? presetConfig?.identifierEscape, parameterSymbol: options.parameterSymbol ?? presetConfig?.parameterSymbol, parameterStyle: options.parameterStyle ?? presetConfig?.parameterStyle, + castStyle: options.castStyle ?? presetConfig?.castStyle, }; this.parser = new SqlPrintTokenParser({ diff --git a/packages/core/tests/parsers/SqlPrintTokenParser.test.ts b/packages/core/tests/parsers/SqlPrintTokenParser.test.ts index f299edaa..1f92428d 100644 --- a/packages/core/tests/parsers/SqlPrintTokenParser.test.ts +++ b/packages/core/tests/parsers/SqlPrintTokenParser.test.ts @@ -130,7 +130,7 @@ describe('SqlPrintTokenParser + SqlPrinter', () => { const sql = printer.print(token); // Assert - expect(sql).toBe('1::"pg_catalog".int4'); + expect(sql).toBe('cast(1 as "pg_catalog".int4)'); }); it('should print string specifier', () => { diff --git a/packages/core/tests/parsers/ValueParser.array-slice.test.ts b/packages/core/tests/parsers/ValueParser.array-slice.test.ts index 1d359d51..8f34b3e8 100644 --- a/packages/core/tests/parsers/ValueParser.array-slice.test.ts +++ b/packages/core/tests/parsers/ValueParser.array-slice.test.ts @@ -28,7 +28,7 @@ describe('ValueParser - Array Slice Syntax (Implemented)', () => { // Complex expressions with array slicing ["Function result with slice", "get_array()[1:2]", 'get_array()[1:2]'], - ["Cast expression with slice", "(column::int[])[1:2]", '("column"::int[])[1:2]'], + ["Cast expression with slice", "(column::int[])[1:2]", '(cast("column" as int[]))[1:2]'], ["Parenthesized expression with slice", "(a + b)[1:2]", '("a" + "b")[1:2]'], ])('%s: %s', (description, input, expected) => { const value = ValueParser.parse(input); @@ -84,7 +84,7 @@ describe('ValueParser - Array Slice Syntax (Implemented)', () => { ["Array function call", "ARRAY(SELECT 1)", "array(select 1)"], ["Parenthesized array", "(ARRAY[1,2,3])", "(array[1, 2, 3])"], ["Simple column reference", "column_name", '"column_name"'], - ["PostgreSQL array literal", "'{1,2,3}'::int[]", "'{1,2,3}'::int[]"], + ["PostgreSQL array literal", "'{1,2,3}'::int[]", "cast('{1,2,3}' as int[])"], ])('%s: %s', (description, input, expected) => { const value = ValueParser.parse(input); const formatted = formatter.format(value); @@ -108,4 +108,4 @@ describe('ValueParser - Array Slice Syntax (Implemented)', () => { expect(slice.endIndex).toBeTruthy(); }); }); -}); \ No newline at end of file +}); diff --git a/packages/core/tests/parsers/ValueParser.test.ts b/packages/core/tests/parsers/ValueParser.test.ts index e910a903..ef1cd505 100644 --- a/packages/core/tests/parsers/ValueParser.test.ts +++ b/packages/core/tests/parsers/ValueParser.test.ts @@ -37,7 +37,7 @@ describe('ValueParser', () => { ["IS NOT DISTINCT FROM operator", "a.value IS NOT DISTINCT FROM b.value", '"a"."value" is not distinct from "b"."value"'], ["Unicode escape (U&'')", "U&'\\0041\\0042\\0043\\0044'", "U&'\\0041\\0042\\0043\\0044'"], ["LIKE escape - percent", "'a_b' LIKE 'a\\_b' ESCAPE '\\'", "'a_b' like 'a\\_b' escape '\\'"], - ["EXTRACT - Extract month from timestamp", "EXTRACT(MONTH FROM '2025-03-21 12:34:56'::timestamp)", "extract(month from '2025-03-21 12:34:56'::timestamp)"], + ["EXTRACT - Extract month from timestamp", "EXTRACT(MONTH FROM '2025-03-21 12:34:56'::timestamp)", "extract(month from cast('2025-03-21 12:34:56' as timestamp))"], ["POSITION function", "POSITION('b' IN 'abc')", 'position(\'b\' in \'abc\')'], ["INTERVAL - Adding time interval", "interval '2 days' + interval '3 hours'", "interval '2 days' + interval '3 hours'"], ["SUBSTRING", "substring('Thomas', 2, 3)", "substring('Thomas', 2, 3)"], @@ -58,22 +58,22 @@ describe('ValueParser', () => { ["CAST with AS syntax", "CAST(id AS INTEGER)", "cast(\"id\" as INTEGER)"], ["CAST with precision", "CAST(price AS NUMERIC(10,2))", "cast(\"price\" as NUMERIC(10, 2))"], ["CAST with length", "CAST(name AS VARCHAR(50))", "cast(\"name\" as VARCHAR(50))"], - ["Postgres CAST with AS syntax", "id::INTEGER", "\"id\"::INTEGER"], - ["Postgres CAST with precision", "price::NUMERIC(10,2)", "\"price\"::NUMERIC(10, 2)"], - ["Postgres CAST with length", "name::VARCHAR(50)", "\"name\"::VARCHAR(50)"], - ["CAST with DOUBLE PRECISION", "value::DOUBLE PRECISION", "\"value\"::DOUBLE PRECISION"], + ["Postgres CAST with AS syntax", "id::INTEGER", "cast(\"id\" as INTEGER)"], + ["Postgres CAST with precision", "price::NUMERIC(10,2)", "cast(\"price\" as NUMERIC(10, 2))"], + ["Postgres CAST with length", "name::VARCHAR(50)", "cast(\"name\" as VARCHAR(50))"], + ["CAST with DOUBLE PRECISION", "value::DOUBLE PRECISION", "cast(\"value\" as DOUBLE PRECISION)"], ["CAST with AS syntax and namespaced type", "CAST(id AS myschema.INTEGER)", "cast(\"id\" as \"myschema\".INTEGER)"], - ["Postgres CAST with :: and namespaced type", "id::myschema.INTEGER", "\"id\"::\"myschema\".INTEGER"], - ["CAST with CHARACTER VARYING", "text::CHARACTER VARYING(100)", "\"text\"::CHARACTER VARYING(100)"], - ["CAST with TIME WITH TIME ZONE", "ts::TIME WITH TIME ZONE", "\"ts\"::TIME WITH TIME ZONE"], - ["CAST with TIMESTAMP WITHOUT TIME ZONE", "date::TIMESTAMP WITHOUT TIME ZONE", "\"date\"::TIMESTAMP WITHOUT TIME ZONE"], - ["Postgres CAST with INTERVAL", "'1 month'::interval", "'1 month'::interval"], + ["Postgres CAST with :: and namespaced type", "id::myschema.INTEGER", "cast(\"id\" as \"myschema\".INTEGER)"], + ["CAST with CHARACTER VARYING", "text::CHARACTER VARYING(100)", "cast(\"text\" as CHARACTER VARYING(100))"], + ["CAST with TIME WITH TIME ZONE", "ts::TIME WITH TIME ZONE", "cast(\"ts\" as TIME WITH TIME ZONE)"], + ["CAST with TIMESTAMP WITHOUT TIME ZONE", "date::TIMESTAMP WITHOUT TIME ZONE", "cast(\"date\" as TIMESTAMP WITHOUT TIME ZONE)"], + ["Postgres CAST with INTERVAL", "'1 month'::interval", "cast('1 month' as interval)"], ["OVERLAY function - basic", "OVERLAY('abcdef' PLACING 'xyz' FROM 2)", "overlay('abcdef' placing 'xyz' from 2)"], ["OVERLAY function - with FOR", "OVERLAY('abcdef' PLACING 'xyz' FROM 2 FOR 3)", "overlay('abcdef' placing 'xyz' from 2 for 3)"], ["AT TIME ZONE - basic", "current_timestamp AT TIME ZONE 'JST'", "current_timestamp at time zone 'JST'"], ["AT TIME ZONE - column reference", "created_at AT TIME ZONE 'UTC'", "\"created_at\" at time zone 'UTC'"], - ["AT TIME ZONE - timestamp literal", "'2025-03-28 15:30:00'::timestamp AT TIME ZONE 'America/New_York'", "'2025-03-28 15:30:00'::timestamp at time zone 'America/New_York'"], - ["AT TIME ZONE - nested", "('2025-03-28 15:30:00'::timestamp AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Tokyo'", "('2025-03-28 15:30:00'::timestamp at time zone 'UTC') at time zone 'Asia/Tokyo'"], + ["AT TIME ZONE - timestamp literal", "'2025-03-28 15:30:00'::timestamp AT TIME ZONE 'America/New_York'", "cast('2025-03-28 15:30:00' as timestamp) at time zone 'America/New_York'"], + ["AT TIME ZONE - nested", "('2025-03-28 15:30:00'::timestamp AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Tokyo'", "(cast('2025-03-28 15:30:00' as timestamp) at time zone 'UTC') at time zone 'Asia/Tokyo'"], ["Window function - simple OVER()", "row_number() OVER()", "row_number() over()"], ["Window function - with PARTITION BY", "rank() OVER(PARTITION BY department_id)", "rank() over(partition by \"department_id\")"], ["Window function - with ORDER BY", "dense_rank() OVER(ORDER BY salary DESC)", "dense_rank() over(order by \"salary\" desc)"], @@ -84,9 +84,9 @@ describe('ValueParser', () => { ["InlineQuery - In comparison", "user_id = (SELECT id FROM users WHERE name = 'Alice')", "\"user_id\" = (select \"id\" from \"users\" where \"name\" = 'Alice')"], ["InlineQuery - With aggregation", "department_id IN (SELECT dept_id FROM departments WHERE active = TRUE)", "\"department_id\" in (select \"dept_id\" from \"departments\" where \"active\" = true)"], ["FunctionCall with arithmetic operation", "count(*) + 1", "count(*) + 1"], - ["Postgres Array Type", ":array::int[]", ":array::int[]"], - ["Postgres Array Type - Single Item", ":item::int", ":item::int"], - ["Postgres Array Type - Multi-dimensional", ":array::int[][]", ":array::int[][]"], + ["Postgres Array Type", ":array::int[]", "cast(:array as int[])"], + ["Postgres Array Type - Single Item", ":item::int", "cast(:item as int)"], + ["Postgres Array Type - Multi-dimensional", ":array::int[][]", "cast(:array as int[][])"], // BETWEEN precedence tests - ensuring BETWEEN binds more tightly than AND/OR ["BETWEEN with AND - precedence test", "age BETWEEN 10 AND 20 AND status = 'active'", '"age" between 10 and 20 and "status" = \'active\''], ["BETWEEN with OR - precedence test", "age BETWEEN 10 AND 20 OR status = 'inactive'", '"age" between 10 and 20 or "status" = \'inactive\''], @@ -94,21 +94,21 @@ describe('ValueParser', () => { ["BETWEEN with complex expressions", "(x + y) BETWEEN 10 AND (z * 2)", '("x" + "y") between 10 and ("z" * 2)'], ["BETWEEN with parentheses", "(age BETWEEN 10 AND 20) OR status = 'active'", '("age" between 10 and 20) or "status" = \'active\''], // Complex type cast tests - covering edge cases from debug scripts - ["Type cast - parameterized NUMERIC", '"value"::NUMERIC(10,2)', '"value"::NUMERIC(10, 2)'], - ["Type cast - parameterized VARCHAR", '"name"::VARCHAR(50)', '"name"::VARCHAR(50)'], - ["Type cast - parameterized DECIMAL", '"data"::DECIMAL(8,4)', '"data"::DECIMAL(8, 4)'], - ["Type cast - parameterized TIMESTAMP", '"timestamp"::TIMESTAMP(6)', '"timestamp"::TIMESTAMP(6)'], - ["Type cast - namespaced type", '"id"::pg_catalog.int4', '"id"::"pg_catalog".int4'], - ["Type cast - public schema type", '"value"::public.custom_type', '"value"::"public".custom_type'], - ["Type cast - complex expression", '(x + y)::INTEGER', '("x" + "y")::INTEGER'], - ["Type cast - CASE expression", 'CASE WHEN a > b THEN a ELSE b END::BIGINT', 'case when "a" > "b" then "a" else "b" end::BIGINT'], - ["Type cast - multiple casts", '"a"::INTEGER::TEXT', '"a"::INTEGER::TEXT'], - ["Type cast - with parentheses", '("col1" || "col2")::VARCHAR(100)', '("col1" || "col2")::VARCHAR(100)'], - ["Type cast - array type", '"id"::int[]', '"id"::int[]'], - ["Type cast - multi-dimensional array", '"data"::int[][]', '"data"::int[][]'], - ["Type cast - type without parameters", '"value"::NUMERIC', '"value"::NUMERIC'], - ["Type cast - literal cast", '123::TEXT', '123::TEXT'], - ["Type cast - function cast", 'NOW()::DATE', 'now()::DATE'], + ["Type cast - parameterized NUMERIC", '"value"::NUMERIC(10,2)', 'cast("value" as NUMERIC(10, 2))'], + ["Type cast - parameterized VARCHAR", '"name"::VARCHAR(50)', 'cast("name" as VARCHAR(50))'], + ["Type cast - parameterized DECIMAL", '"data"::DECIMAL(8,4)', 'cast("data" as DECIMAL(8, 4))'], + ["Type cast - parameterized TIMESTAMP", '"timestamp"::TIMESTAMP(6)', 'cast("timestamp" as TIMESTAMP(6))'], + ["Type cast - namespaced type", '"id"::pg_catalog.int4', 'cast("id" as "pg_catalog".int4)'], + ["Type cast - public schema type", '"value"::public.custom_type', 'cast("value" as "public".custom_type)'], + ["Type cast - complex expression", '(x + y)::INTEGER', 'cast(("x" + "y") as INTEGER)'], + ["Type cast - CASE expression", 'CASE WHEN a > b THEN a ELSE b END::BIGINT', 'cast(case when "a" > "b" then "a" else "b" end as BIGINT)'], + ["Type cast - multiple casts", '"a"::INTEGER::TEXT', 'cast(cast("a" as INTEGER) as TEXT)'], + ["Type cast - with parentheses", '("col1" || "col2")::VARCHAR(100)', 'cast(("col1" || "col2") as VARCHAR(100))'], + ["Type cast - array type", '"id"::int[]', 'cast("id" as int[])'], + ["Type cast - multi-dimensional array", '"data"::int[][]', 'cast("data" as int[][])'], + ["Type cast - type without parameters", '"value"::NUMERIC', 'cast("value" as NUMERIC)'], + ["Type cast - literal cast", '123::TEXT', 'cast(123 as TEXT)'], + ["Type cast - function cast", 'NOW()::DATE', 'cast(now() as DATE)'], // Complex logical expressions to ensure no regression ["Complex logical - nested AND/OR", 'a = 1 AND (b = 2 OR c = 3) AND d = 4', '"a" = 1 and ("b" = 2 or "c" = 3) and "d" = 4'], ["Complex logical - mixed with BETWEEN", 'a BETWEEN 1 AND 5 AND (b = 2 OR c BETWEEN 10 AND 20)', '"a" between 1 and 5 and ("b" = 2 or "c" between 10 and 20)'], diff --git a/packages/core/tests/transformers/CTEDisabler.test.ts b/packages/core/tests/transformers/CTEDisabler.test.ts index 4c64340a..baec89dd 100644 --- a/packages/core/tests/transformers/CTEDisabler.test.ts +++ b/packages/core/tests/transformers/CTEDisabler.test.ts @@ -410,7 +410,7 @@ describe('CTEDisabler', () => { const formattedResult = customFormatter.format(disabledQuery); // Expected result with CTEs completely removed (one-line format as produced by the formatter) - const expectedSql = `select 'Engagement Overview' as "analysis_type", "uc"."engagement_level" as "segment", count(*) as "user_count", round(avg("uc"."total_sessions"), 2) as "avg_sessions", round(avg("uc"."active_days"), 2) as "avg_active_days", round(avg("uc"."total_time_spent"), 2) as "avg_time_spent_minutes", round(sum(case when "uc"."has_purchase" = 1 then 1 else 0 end)::DECIMAL / count(*) * 100, 2) as "conversion_rate_percent", round(avg("uc"."total_purchase_value"), 2) as "avg_purchase_value" from "user_cohorts" as "uc" group by "uc"."engagement_level" union all select 'Channel Analysis' as "analysis_type", "cp"."source_channel" as "segment", "cp"."unique_visitors" as "user_count", round("cp"."avg_session_duration", 2) as "avg_sessions", "cp"."total_sessions" as "avg_active_days", 0 as "avg_time_spent_minutes", round(case when "cp"."unique_visitors" > 0 then ("cp"."conversions"::DECIMAL / "cp"."unique_visitors") * 100 else 0 end, 2) as "conversion_rate_percent", round("cp"."revenue_generated", 2) as "avg_purchase_value" from "channel_performance" as "cp" union all select 'Device Performance' as "analysis_type", concat("da"."device_type", ' - ', "da"."browser") as "segment", "da"."unique_users" as "user_count", round("da"."avg_session_duration", 2) as "avg_sessions", "da"."total_sessions" as "avg_active_days", round("da"."avg_page_views_per_session", 2) as "avg_time_spent_minutes", round("da"."avg_actions_per_session", 2) as "conversion_rate_percent", 0 as "avg_purchase_value" from "device_analysis" as "da" order by case "analysis_type" when 'Engagement Overview' then 1 when 'Channel Analysis' then 2 when 'Device Performance' then 3 end, "user_count" desc`; + const expectedSql = `select 'Engagement Overview' as "analysis_type", "uc"."engagement_level" as "segment", count(*) as "user_count", round(avg("uc"."total_sessions"), 2) as "avg_sessions", round(avg("uc"."active_days"), 2) as "avg_active_days", round(avg("uc"."total_time_spent"), 2) as "avg_time_spent_minutes", round(cast(sum(case when "uc"."has_purchase" = 1 then 1 else 0 end) as DECIMAL) / count(*) * 100, 2) as "conversion_rate_percent", round(avg("uc"."total_purchase_value"), 2) as "avg_purchase_value" from "user_cohorts" as "uc" group by "uc"."engagement_level" union all select 'Channel Analysis' as "analysis_type", "cp"."source_channel" as "segment", "cp"."unique_visitors" as "user_count", round("cp"."avg_session_duration", 2) as "avg_sessions", "cp"."total_sessions" as "avg_active_days", 0 as "avg_time_spent_minutes", round(case when "cp"."unique_visitors" > 0 then (cast("cp"."conversions" as DECIMAL) / "cp"."unique_visitors") * 100 else 0 end, 2) as "conversion_rate_percent", round("cp"."revenue_generated", 2) as "avg_purchase_value" from "channel_performance" as "cp" union all select 'Device Performance' as "analysis_type", concat("da"."device_type", ' - ', "da"."browser") as "segment", "da"."unique_users" as "user_count", round("da"."avg_session_duration", 2) as "avg_sessions", "da"."total_sessions" as "avg_active_days", round("da"."avg_page_views_per_session", 2) as "avg_time_spent_minutes", round("da"."avg_actions_per_session", 2) as "conversion_rate_percent", 0 as "avg_purchase_value" from "device_analysis" as "da" order by case "analysis_type" when 'Engagement Overview' then 1 when 'Channel Analysis' then 2 when 'Device Performance' then 3 end, "user_count" desc`; // Compare the actual formatted result with expected SQL expect(formattedResult).toBe(expectedSql); diff --git a/packages/core/tests/transformers/SqlFormatter.cast-style.test.ts b/packages/core/tests/transformers/SqlFormatter.cast-style.test.ts new file mode 100644 index 00000000..61957feb --- /dev/null +++ b/packages/core/tests/transformers/SqlFormatter.cast-style.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { SqlFormatter } from '../../src/transformers/SqlFormatter'; +import { SelectQueryParser } from '../../src/parsers/SelectQueryParser'; + +describe('SqlFormatter cast style', () => { + it('emits ANSI CAST syntax by default', () => { + const query = SelectQueryParser.parse('SELECT id::INTEGER FROM users'); + const sql = new SqlFormatter().format(query).formattedSql; + + expect(sql).toBe('select cast("id" as INTEGER) from "users"'); + }); + + it('emits PostgreSQL :: syntax when preset requires it', () => { + const query = SelectQueryParser.parse('SELECT id::INTEGER FROM users'); + const sql = new SqlFormatter({ preset: 'postgres' }).format(query).formattedSql; + + expect(sql).toBe('select "id"::INTEGER from "users"'); + }); +}); diff --git a/packages/core/tests/transformers/buildDeleteQuery.test.ts b/packages/core/tests/transformers/buildDeleteQuery.test.ts index aae099af..89c67d98 100644 --- a/packages/core/tests/transformers/buildDeleteQuery.test.ts +++ b/packages/core/tests/transformers/buildDeleteQuery.test.ts @@ -15,7 +15,7 @@ describe('buildDeleteQuery', () => { }); 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"'); + expect(sql).toBe('delete from "users" as "u" where exists (select 1 from (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', () => { @@ -29,7 +29,7 @@ describe('buildDeleteQuery', () => { }); 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"'); + expect(sql).toBe('delete from "users" where exists (select 1 from (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', () => { @@ -44,7 +44,7 @@ describe('buildDeleteQuery', () => { 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"' + 'with "flagged" as (select "id" from "users_staging" where "flagged" = true) delete from "users" where exists (select 1 from (select "id" from "flagged") as "src" where "users"."id" = "src"."id")' ); }); @@ -57,7 +57,7 @@ describe('buildDeleteQuery', () => { }); 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"'); + expect(sql).toBe('delete from "users" where exists (select 1 from (select "id" from "users_staging") as "src" where "users"."id" = "src"."id")'); }); it('deduplicates composite primary keys when building predicates', () => { @@ -72,7 +72,7 @@ describe('buildDeleteQuery', () => { 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"' + 'delete from "users" where exists (select 1 from (select "id", "tenant_id" from "users_staging") as "alias_src" where "users"."id" = "alias_src"."id" and "users"."tenant_id" = "alias_src"."tenant_id")' ); }); }); From a13a655b2eda67695da6ecf943754e01b9a9a773 Mon Sep 17 00:00:00 2001 From: MSugiura Date: Fri, 24 Oct 2025 22:23:23 +0900 Subject: [PATCH 2/2] docs: update castStyle option description in formatting-recipes.md to include usage notes and examples --- docs/guide/formatting-recipes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/formatting-recipes.md b/docs/guide/formatting-recipes.md index a2c41a8e..5391c6c7 100644 --- a/docs/guide/formatting-recipes.md +++ b/docs/guide/formatting-recipes.md @@ -36,7 +36,7 @@ const { formattedSql, params } = formatter.format(query); | `withClauseStyle` | `'standard'`, `'cte-oneline'`, `'full-oneline'` | `'standard'` | Expands or collapses common table expressions. | | `parenthesesOneLine`, `betweenOneLine`, `valuesOneLine`, `joinOneLine`, `caseOneLine`, `subqueryOneLine` | `true` / `false` | `false` for each | Opt-in switches that keep the corresponding construct on a single line even if other break settings would expand it. | | `exportComment` | `true` / `false` | `false` | Emits comments collected by the parser. Turn it on when you want annotations preserved. | -| `castStyle` | 'standard', 'postgres' | From preset or 'standard' | Chooses how CAST expressions are printed. 'standard' emits ANSI `CAST(expr AS type)` while 'postgres' emits `expr::type`. | +| `castStyle` | 'standard', 'postgres' | From preset or 'standard' | Chooses how CAST expressions are printed. 'standard' emits ANSI `CAST(expr AS type)` while 'postgres' emits `expr::type`. See "Controlling CAST style" below for usage notes and examples. | Combine these settings to mirror house formatting conventions or align with existing lint rules. The following sections call out the options that trip up newcomers most often.