From 4e30fe6bd3ee16d61b4e8cd9254e4cbea7c6d69d Mon Sep 17 00:00:00 2001 From: Joel Mukuthu Date: Sat, 17 Jan 2026 14:44:44 +0100 Subject: [PATCH 1/5] Format LANGUAGE SQL function bodies --- src/embed.ts | 3 +- src/embedSql.ts | 56 ++++++++++++++++++++++++++++++++ test/ddl/function.test.ts | 68 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/embedSql.ts diff --git a/src/embed.ts b/src/embed.ts index 3f99bad..35218be 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -2,7 +2,8 @@ import { Printer } from "prettier"; import { Node } from "sql-parser-cst"; import { embedJs } from "./embedJs"; import { embedJson } from "./embedJson"; +import { embedSql } from "./embedSql"; export const embed: NonNullable["embed"]> = (...args) => { - return embedJson(...args) || embedJs(...args); + return embedJson(...args) || embedJs(...args) || embedSql(...args); }; diff --git a/src/embedSql.ts b/src/embedSql.ts new file mode 100644 index 0000000..a245b44 --- /dev/null +++ b/src/embedSql.ts @@ -0,0 +1,56 @@ +import { Printer } from "prettier"; +import { CreateFunctionStmt, Node, StringLiteral } from "sql-parser-cst"; +import { + isAsClause, + isCreateFunctionStmt, + isLanguageClause, + isStringLiteral, +} from "./node_utils"; +import { hardline, indent, stripTrailingHardline } from "./print_utils"; + +export const embedSql: NonNullable["embed"]> = (path, options) => { + const node = path.node; + const parent = path.getParentNode(0); + const grandParent = path.getParentNode(1); + + if ( + isStringLiteral(node) && + isAsClause(parent) && + isCreateFunctionStmt(grandParent) && + grandParent.clauses.some(isSqlLanguageClause) + ) { + return async (textToDoc) => { + const quote = detectQuote(node); + + if (quote) { + const sql = await textToDoc(node.value, options); + + return [ + quote, + indent([hardline, stripTrailingHardline(sql)]), + hardline, + quote, + ]; + } + }; + } + + return null; +}; + +const isSqlLanguageClause = ( + clause: CreateFunctionStmt["clauses"][0], +): boolean => isLanguageClause(clause) && clause.name.name === "sql"; + +const detectQuote = ( + node: StringLiteral, +): string | undefined => { + const match = node.text.match(/^('|\$[^$]*\$)/); + const quote = match?.[1]; + + if (quote && node.text.endsWith(quote)) { + return quote; + } + + return undefined; +}; diff --git a/test/ddl/function.test.ts b/test/ddl/function.test.ts index b5d8abc..f6e96b0 100644 --- a/test/ddl/function.test.ts +++ b/test/ddl/function.test.ts @@ -202,6 +202,74 @@ describe("function", () => { AS " return /'''|\\"\\"\\"/.test(x) " `); }); + + it(`formats dollar-quoted SQL function`, async () => { + await testPostgresql(dedent` + CREATE FUNCTION my_func() + RETURNS INT64 + LANGUAGE sql + AS $$ + SELECT 1; + $$ + `); + }); + + it(`reformats SQL in dollar-quoted SQL function`, async () => { + expect( + await pretty( + dedent` + CREATE FUNCTION my_func() + RETURNS INT64 + LANGUAGE sql + AS $body$SELECT 1; + select 2$body$ + `, + { dialect: "postgresql" }, + ), + ).toBe(dedent` + CREATE FUNCTION my_func() + RETURNS INT64 + LANGUAGE sql + AS $body$ + SELECT 1; + SELECT 2; + $body$ + `); + }); + + it(`formats single-quoted SQL function`, async () => { + await testPostgresql(dedent` + CREATE FUNCTION my_func() + RETURNS TEXT + LANGUAGE sql + AS ' + SELECT ''foo''; + ' + `); + }); + + it(`reformats SQL in single-quoted SQL function`, async () => { + expect( + await pretty( + dedent` + CREATE FUNCTION my_func() + RETURNS TEXT + LANGUAGE sql + AS 'SELECT ''foo''; + select ''bar''' + `, + { dialect: "postgresql" }, + ), + ).toBe(dedent` + CREATE FUNCTION my_func() + RETURNS TEXT + LANGUAGE sql + AS ' + SELECT ''foo''; + SELECT ''bar''; + ' + `); + }); }); describe("drop function", () => { From c354f94a6622128b75847971e226d37b31c3f2d1 Mon Sep 17 00:00:00 2001 From: Joel Mukuthu Date: Sun, 18 Jan 2026 11:37:16 +0100 Subject: [PATCH 2/5] Skip checking SQL function closing quotes This is already handled by the parser. --- src/embedSql.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/embedSql.ts b/src/embedSql.ts index a245b44..b557fd3 100644 --- a/src/embedSql.ts +++ b/src/embedSql.ts @@ -46,11 +46,5 @@ const detectQuote = ( node: StringLiteral, ): string | undefined => { const match = node.text.match(/^('|\$[^$]*\$)/); - const quote = match?.[1]; - - if (quote && node.text.endsWith(quote)) { - return quote; - } - - return undefined; + return match?.[1]; }; From e3372f2ba9f693be7c45c6949d434b59b14a6692 Mon Sep 17 00:00:00 2001 From: Joel Mukuthu Date: Sun, 18 Jan 2026 11:38:47 +0100 Subject: [PATCH 3/5] Convert single-quoted SQL functions to dollar-quoted --- src/embedSql.ts | 29 ++++++++++++++++++++--------- test/ddl/function.test.ts | 30 ++++++++++++++++++------------ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/embedSql.ts b/src/embedSql.ts index b557fd3..2f93222 100644 --- a/src/embedSql.ts +++ b/src/embedSql.ts @@ -20,18 +20,29 @@ export const embedSql: NonNullable["embed"]> = (path, options) => grandParent.clauses.some(isSqlLanguageClause) ) { return async (textToDoc) => { - const quote = detectQuote(node); + let quote = detectQuote(node); - if (quote) { - const sql = await textToDoc(node.value, options); + if (!quote) { + return; + } - return [ - quote, - indent([hardline, stripTrailingHardline(sql)]), - hardline, - quote, - ]; + if (quote === "'") { + // Convert `'` quotes to `$$` to simplify handling of strings inside the + // function. But bail out if the function contains dollar-quoted strings. + if (node.value.includes("$$")) { + return; + } + quote = "$$"; } + + const sql = await textToDoc(node.value, options); + + return [ + quote, + indent([hardline, stripTrailingHardline(sql)]), + hardline, + quote, + ]; }; } diff --git a/test/ddl/function.test.ts b/test/ddl/function.test.ts index f6e96b0..58908f1 100644 --- a/test/ddl/function.test.ts +++ b/test/ddl/function.test.ts @@ -237,26 +237,35 @@ describe("function", () => { `); }); - it(`formats single-quoted SQL function`, async () => { - await testPostgresql(dedent` + it(`converts single-quoted SQL functions to dollar-quoted SQL functions`, async () => { + expect( + await pretty( + dedent` + CREATE FUNCTION my_func() + RETURNS TEXT + LANGUAGE sql + AS 'SELECT ''foo''' + `, + { dialect: "postgresql" }, + ), + ).toBe(dedent` CREATE FUNCTION my_func() RETURNS TEXT LANGUAGE sql - AS ' - SELECT ''foo''; - ' + AS $$ + SELECT 'foo'; + $$ `); }); - it(`reformats SQL in single-quoted SQL function`, async () => { + it(`does not convert single-quoted SQL functions to dollar-quoted SQL functions when they contain dollar-quoted strings`, async () => { expect( await pretty( dedent` CREATE FUNCTION my_func() RETURNS TEXT LANGUAGE sql - AS 'SELECT ''foo''; - select ''bar''' + AS 'SELECT $$foo$$' `, { dialect: "postgresql" }, ), @@ -264,10 +273,7 @@ describe("function", () => { CREATE FUNCTION my_func() RETURNS TEXT LANGUAGE sql - AS ' - SELECT ''foo''; - SELECT ''bar''; - ' + AS 'SELECT $$foo$$' `); }); }); From ae1bf5f4953ce6e1da7b3e2f0df7510dfbd8efa1 Mon Sep 17 00:00:00 2001 From: Joel Mukuthu Date: Sun, 18 Jan 2026 11:48:18 +0100 Subject: [PATCH 4/5] Handle SQL language-identifier case-insensitively --- src/embedSql.ts | 2 +- test/ddl/function.test.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/embedSql.ts b/src/embedSql.ts index 2f93222..54ff677 100644 --- a/src/embedSql.ts +++ b/src/embedSql.ts @@ -51,7 +51,7 @@ export const embedSql: NonNullable["embed"]> = (path, options) => const isSqlLanguageClause = ( clause: CreateFunctionStmt["clauses"][0], -): boolean => isLanguageClause(clause) && clause.name.name === "sql"; +): boolean => isLanguageClause(clause) && clause.name.name.toLowerCase() === "sql"; const detectQuote = ( node: StringLiteral, diff --git a/test/ddl/function.test.ts b/test/ddl/function.test.ts index 58908f1..e44d6f6 100644 --- a/test/ddl/function.test.ts +++ b/test/ddl/function.test.ts @@ -276,6 +276,27 @@ describe("function", () => { AS 'SELECT $$foo$$' `); }); + + it(`handles SQL language identifier case-insensitively`, async () => { + expect( + await pretty( + dedent` + CREATE FUNCTION my_func() + RETURNS INT64 + LANGUAGE Sql + AS 'SELECT 1' + `, + { dialect: "postgresql" }, + ), + ).toBe(dedent` + CREATE FUNCTION my_func() + RETURNS INT64 + LANGUAGE Sql + AS $$ + SELECT 1; + $$ + `); + }); }); describe("drop function", () => { From 966121ae108dc52e6b13bd9e4b53d2afb3d96d3a Mon Sep 17 00:00:00 2001 From: Joel Mukuthu Date: Sun, 18 Jan 2026 11:59:26 +0100 Subject: [PATCH 5/5] Format LANGUAGE SQL procedure bodies --- src/embedSql.ts | 12 ++++-- test/ddl/procedure.test.ts | 88 +++++++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/embedSql.ts b/src/embedSql.ts index 54ff677..5fe67d2 100644 --- a/src/embedSql.ts +++ b/src/embedSql.ts @@ -1,8 +1,14 @@ import { Printer } from "prettier"; -import { CreateFunctionStmt, Node, StringLiteral } from "sql-parser-cst"; +import { + CreateFunctionStmt, + CreateProcedureStmt, + Node, + StringLiteral +} from "sql-parser-cst"; import { isAsClause, isCreateFunctionStmt, + isCreateProcedureStmt, isLanguageClause, isStringLiteral, } from "./node_utils"; @@ -16,7 +22,7 @@ export const embedSql: NonNullable["embed"]> = (path, options) => if ( isStringLiteral(node) && isAsClause(parent) && - isCreateFunctionStmt(grandParent) && + (isCreateFunctionStmt(grandParent) || isCreateProcedureStmt(grandParent)) && grandParent.clauses.some(isSqlLanguageClause) ) { return async (textToDoc) => { @@ -50,7 +56,7 @@ export const embedSql: NonNullable["embed"]> = (path, options) => }; const isSqlLanguageClause = ( - clause: CreateFunctionStmt["clauses"][0], + clause: CreateFunctionStmt["clauses"][0] | CreateProcedureStmt['clauses'][0], ): boolean => isLanguageClause(clause) && clause.name.name.toLowerCase() === "sql"; const detectQuote = ( diff --git a/test/ddl/procedure.test.ts b/test/ddl/procedure.test.ts index aa60597..4b99c96 100644 --- a/test/ddl/procedure.test.ts +++ b/test/ddl/procedure.test.ts @@ -1,5 +1,5 @@ import dedent from "dedent-js"; -import { testBigquery, testPostgresql } from "../test_utils"; +import { pretty, testBigquery, testPostgresql } from "../test_utils"; describe("procedure", () => { describe("create procedure", () => { @@ -90,6 +90,92 @@ describe("procedure", () => { `, ); }); + + it(`formats dollar-quoted SQL procedure`, async () => { + await testPostgresql(dedent` + CREATE PROCEDURE my_proc() + LANGUAGE sql + AS $$ + SELECT 1; + $$ + `); + }); + + it(`reformats SQL in dollar-quoted SQL procedure`, async () => { + expect( + await pretty( + dedent` + CREATE PROCEDURE my_proc() + LANGUAGE sql + AS $body$SELECT 1; + select 2$body$ + `, + { dialect: "postgresql" }, + ), + ).toBe(dedent` + CREATE PROCEDURE my_proc() + LANGUAGE sql + AS $body$ + SELECT 1; + SELECT 2; + $body$ + `); + }); + + it(`converts single-quoted SQL procedures to dollar-quoted SQL procedures`, async () => { + expect( + await pretty( + dedent` + CREATE PROCEDURE my_proc() + LANGUAGE sql + AS 'SELECT ''foo''' + `, + { dialect: "postgresql" }, + ), + ).toBe(dedent` + CREATE PROCEDURE my_proc() + LANGUAGE sql + AS $$ + SELECT 'foo'; + $$ + `); + }); + + it(`does not convert single-quoted SQL procedures to dollar-quoted SQL procedures when they contain dollar-quoted strings`, async () => { + expect( + await pretty( + dedent` + CREATE PROCEDURE my_proc() + LANGUAGE sql + AS 'SELECT $$foo$$' + `, + { dialect: "postgresql" }, + ), + ).toBe(dedent` + CREATE PROCEDURE my_proc() + LANGUAGE sql + AS 'SELECT $$foo$$' + `); + }); + + it(`handles SQL language identifier case-insensitively`, async () => { + expect( + await pretty( + dedent` + CREATE PROCEDURE my_proc() + LANGUAGE Sql + AS 'SELECT 1' + `, + { dialect: "postgresql" }, + ), + ).toBe(dedent` + CREATE PROCEDURE my_proc() + LANGUAGE Sql + AS $$ + SELECT 1; + $$ + `); + }); }); describe("drop procedure", () => {