From 482b9b01af99334be8e0ef4f7c45ab3924cc2f76 Mon Sep 17 00:00:00 2001 From: MSugiura Date: Sun, 26 Oct 2025 11:16:28 +0900 Subject: [PATCH 1/3] feat: add support for INSERT clause formatting and options - Introduced `InsertClause` to `SqlPrintTokenContainerType` for better handling of INSERT statements. - Updated `SqlPrintTokenParser` to create tokens for the INSERT clause. - Enhanced `SqlFormatter` with `insertColumnsOneLine` option to control single-line formatting of column lists. - Modified `SqlPrinter` to respect the new formatting option and improve handling of commas and spaces in INSERT statements. - Added tests for various INSERT formatting scenarios, including single-line column lists and preservation of comments in VALUES rows. --- docs/guide/formatting-recipes.md | 29 +++ docs/public/demo/analysis-features.js | 167 ++++++++++++---- docs/public/demo/index.html | 2 +- docs/public/demo/script.js | 159 ++++++++++++++- docs/public/demo/vendor/rawsql.browser.js | 92 +++++++++ packages/core/src/models/SqlPrintToken.ts | 1 + .../core/src/parsers/SqlPrintTokenParser.ts | 2 +- .../core/src/transformers/SqlFormatter.ts | 2 + packages/core/src/transformers/SqlPrinter.ts | 189 +++++++++++++++++- .../core/tests/SqlFormatter.insert.test.ts | 113 +++++++++++ .../tests/parsers/InsertQueryParser.test.ts | 10 - .../transformers/CTEQueryDecomposer.test.ts | 4 +- 12 files changed, 700 insertions(+), 70 deletions(-) create mode 100644 docs/public/demo/vendor/rawsql.browser.js create mode 100644 packages/core/tests/SqlFormatter.insert.test.ts diff --git a/docs/guide/formatting-recipes.md b/docs/guide/formatting-recipes.md index a2c7d5da..d814b45d 100644 --- a/docs/guide/formatting-recipes.md +++ b/docs/guide/formatting-recipes.md @@ -31,6 +31,7 @@ const { formattedSql, params } = formatter.format(query); | `valuesCommaBreak` | Same as `commaBreak` | Mirrors `commaBreak` | Comma handling within `VALUES` tuples. | | `andBreak` | `'none'`, `'before'`, `'after'` | `'none'` | Controls whether logical `AND` operators move to their own lines. | | `orBreak` | `'none'`, `'before'`, `'after'` | `'none'` | Same idea for logical `OR` operators. | +| `insertColumnsOneLine` | `true` / `false` | `false` | Keeps column lists inside `INSERT INTO` statements on a single line when `true`. | | `indentNestedParentheses` | `true` / `false` | `false` | Adds an extra indent when boolean groups introduce parentheses inside `WHERE` or `HAVING` clauses. | | `commentStyle` | `'block'`, `'smart'` | `'block'` | Normalises how comments are emitted (see below). | | `withClauseStyle` | `'standard'`, `'cte-oneline'`, `'full-oneline'` | `'standard'` | Expands or collapses common table expressions. | @@ -78,6 +79,34 @@ const formatter = new SqlFormatter({ Choose `'before'` when you want to scan down logical branches quickly, or `'after'` to keep complex conditions aligned underneath their keywords. +### INSERT column list layouts + +`insertColumnsOneLine` gives you a dedicated switch for shaping `INSERT INTO` column lists without disturbing the rest of your comma settings. + +- `false` (default) expands each column when you combine it with `commaBreak: 'before'` or `'after'`: + ```typescript + const formatter = new SqlFormatter({ + newline: 'lf', + commaBreak: 'before' + }); + // insert into table_a( + // id + // , value + // ) + // values ... + ``` +- `true` keeps the table name and columns on one line, while `valuesCommaBreak` continues to control the `VALUES` tuples: + ```typescript + const formatter = new SqlFormatter({ + newline: 'lf', + insertColumnsOneLine: true + }); + // insert into table_a(id, value) + // values ... + ``` + +The two insert layouts make it easy to adopt either a compact DML style or a vertically aligned style without rewriting other recipes. + ### Comment style tips Set `commentStyle: 'smart'` when you want single-line annotations to become SQL line comments (`-- like this`) while multi-line explanations are preserved as block comments. Separator banners such as `/* ===== */` stay grouped, and consecutive block comments continue to merge into a readable multi-line block. diff --git a/docs/public/demo/analysis-features.js b/docs/public/demo/analysis-features.js index caf12237..14493d9e 100644 --- a/docs/public/demo/analysis-features.js +++ b/docs/public/demo/analysis-features.js @@ -1,8 +1,14 @@ // analysis-features.js // This module handles SQL analysis features like updating table lists, CTE lists, and schema information. -// Import rawsql-ts modules -import { SelectQueryParser, TableSourceCollector, CTECollector, SchemaCollector } from "https://unpkg.com/rawsql-ts/dist/esm/index.min.js"; +// Import rawsql-ts modules from local vendor bundle for consistent class instances +import { + SqlParser, + TableSourceCollector, + CTECollector, + SchemaCollector, + MultiQuerySplitter +} from './vendor/rawsql.browser.js'; let tableListElement, cteListElement, schemaInfoEditorInstance, sqlInputElement, debounceDelayMs; @@ -24,10 +30,63 @@ export function initAnalysisFeatures(options) { setupSchemaInfoAutoUpdate(); } +let parseCache = { text: null, splitResult: null, statements: null, errors: null }; + +function getParseResult(sqlText) { + if (parseCache.text === sqlText) { + return parseCache; + } + + const splitResult = MultiQuerySplitter.split(sqlText); + const statements = new Map(); + const errors = []; + + for (const query of splitResult.queries) { + if (query.isEmpty) { + continue; + } + + try { + const ast = SqlParser.parse(query.sql); + statements.set(query.index, { ast, sql: query.sql }); + } catch (error) { + errors.push({ + index: query.index, + message: error instanceof Error ? error.message : String(error) + }); + } + } + + parseCache = { text: sqlText, splitResult, statements, errors }; + return parseCache; +} + +function isSelectStatement(ast) { + return Boolean(ast && typeof ast === 'object' && ast.__selectQueryType); +} + +// Extract SELECT statements from the current SQL text for downstream analysis. +function extractSelectStatements(sqlText) { + const { splitResult, statements } = getParseResult(sqlText); + const selectStatements = []; + + for (const query of splitResult.queries) { + if (query.isEmpty) { + continue; + } + const parsed = statements.get(query.index); + if (parsed && isSelectStatement(parsed.ast)) { + selectStatements.push(parsed); + } + } + + return selectStatements; +} + // --- Table List Logic --- function updateTableList(sqlText) { if (!tableListElement) return; - tableListElement.innerHTML = ''; // Clear previous list + tableListElement.innerHTML = ''; if (!sqlText.trim()) { const li = document.createElement('li'); @@ -37,28 +96,43 @@ function updateTableList(sqlText) { } try { - const ast = SelectQueryParser.parse(sqlText); - const collector = new TableSourceCollector(false); - const tables = collector.collect(ast); + const selectStatements = extractSelectStatements(sqlText); + if (selectStatements.length === 0) { + const li = document.createElement('li'); + li.textContent = '(No SELECT statements found)'; + tableListElement.appendChild(li); + return; + } + + const tableNames = new Set(); + for (const { ast } of selectStatements) { + const collector = new TableSourceCollector(false); + const tables = collector.collect(ast); + tables.forEach(t => { + if (t?.table?.name) { + tableNames.add(t.table.name); + } + }); + } - if (tables.length === 0) { + if (tableNames.size === 0) { const li = document.createElement('li'); li.textContent = '(No tables found)'; tableListElement.appendChild(li); - } else { - const uniqueTableNames = [...new Set(tables.map(t => t.table.name))]; - uniqueTableNames.forEach(tableName => { - const li = document.createElement('li'); - li.textContent = tableName; - tableListElement.appendChild(li); - }); + return; } + + Array.from(tableNames).forEach(tableName => { + const li = document.createElement('li'); + li.textContent = tableName; + tableListElement.appendChild(li); + }); } catch (error) { - console.error("Error parsing SQL for table list:", error); + console.error('Error parsing SQL for table list:', error); const li = document.createElement('li'); let errorText = '(Error parsing SQL for table list)'; - if (error.name === 'ParseError' && error.message) { - errorText = `(Parse Error: ${error.message.substring(0, 50)}...)`; // Keep it short + if (error?.message) { + errorText = `(Error: ${error.message.substring(0, 80)}...)`; } li.textContent = errorText; tableListElement.appendChild(li); @@ -72,7 +146,6 @@ function setupTableListAutoUpdate() { if (tableListDebounceTimer) clearTimeout(tableListDebounceTimer); tableListDebounceTimer = setTimeout(() => updateTableList(sqlInputElement.getValue()), debounceDelayMs); }); - // Initial population updateTableList(sqlInputElement.getValue()); } @@ -88,26 +161,44 @@ function updateCTEList(sqlText) { } try { - const query = SelectQueryParser.parse(sqlText); - const cteCollector = new CTECollector(); - const ctes = cteCollector.collect(query); - if (ctes.length > 0) { + const selectStatements = extractSelectStatements(sqlText); + if (selectStatements.length === 0) { + const listItem = document.createElement('li'); + listItem.textContent = '(No SELECT statements found)'; + cteListElement.appendChild(listItem); + return; + } + + const cteNames = new Set(); + for (const { ast } of selectStatements) { + const collector = new CTECollector(); + const ctes = collector.collect(ast); ctes.forEach(cte => { - const listItem = document.createElement('li'); - listItem.textContent = cte.getSourceAliasName(); - cteListElement.appendChild(listItem); + const name = cte.getSourceAliasName(); + if (name) { + cteNames.add(name); + } }); - } else { + } + + if (cteNames.size === 0) { const listItem = document.createElement('li'); listItem.textContent = '(No CTEs found)'; cteListElement.appendChild(listItem); + return; } + + Array.from(cteNames).forEach(name => { + const listItem = document.createElement('li'); + listItem.textContent = name; + cteListElement.appendChild(listItem); + }); } catch (error) { - console.error("Error collecting CTEs:", error); + console.error('Error collecting CTEs:', error); const listItem = document.createElement('li'); let errorText = 'Error collecting CTEs.'; - if (error.name === 'ParseError' && error.message) { - errorText = `(Parse Error: ${error.message.substring(0, 50)}...)`; // Keep it short + if (error?.message) { + errorText = `(Error: ${error.message.substring(0, 80)}...)`; } listItem.textContent = errorText; cteListElement.appendChild(listItem); @@ -121,7 +212,6 @@ function setupCTEListAutoUpdate() { if (cteListDebounceTimer) clearTimeout(cteListDebounceTimer); cteListDebounceTimer = setTimeout(() => updateCTEList(sqlInputElement.getValue()), debounceDelayMs); }); - // Initial population updateCTEList(sqlInputElement.getValue()); } @@ -136,7 +226,15 @@ function updateSchemaInfo(sqlText) { } try { - const query = SelectQueryParser.parse(sqlText); + const selectStatements = extractSelectStatements(sqlText); + if (selectStatements.length === 0) { + schemaInfoEditorInstance.setValue('(No SELECT statements found for schema analysis)'); + schemaInfoEditorInstance.refresh(); + return; + } + + // Focus on the first SELECT statement for schema introspection. + const query = selectStatements[0].ast; const schemaCollector = new SchemaCollector(); const schemaInfo = schemaCollector.collect(query); @@ -148,12 +246,7 @@ function updateSchemaInfo(sqlText) { } catch (error) { console.error("Error collecting schema info:", error); let errorMessage = 'Error collecting schema info.'; - if (error.name === 'ParseError' && error.message) { - errorMessage = `Error parsing SQL for schema: ${error.message}`; - if (error.details) { - errorMessage += `\nAt line ${error.details.startLine}, column ${error.details.startColumn}. Found: '${error.details.found}'`; - } - } else if (error.message) { + if (error?.message) { errorMessage = `Error collecting schema info: ${error.message}`; } schemaInfoEditorInstance.setValue(errorMessage); diff --git a/docs/public/demo/index.html b/docs/public/demo/index.html index 3c9761af..e55de235 100644 --- a/docs/public/demo/index.html +++ b/docs/public/demo/index.html @@ -28,7 +28,7 @@

rawsql-ts