sql-fmt-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +61 -0
  2. package/index.js +410 -0
  3. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # sql-fmt-cli
2
+
3
+ > Format SQL queries from stdin or file — uppercase keywords, one clause per line, indent subqueries. Zero dependencies.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/sql-fmt-cli.svg)](https://www.npmjs.com/package/sql-fmt-cli)
6
+ [![license](https://img.shields.io/npm/l/sql-fmt-cli.svg)](LICENSE)
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install -g sql-fmt-cli
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ # Format a file in-place (prints to stdout)
18
+ sqlfmt query.sql
19
+
20
+ # Pipe from stdin
21
+ echo "select id,name from users where active=1 order by name" | sqlfmt
22
+
23
+ # CI check — exits 1 if not formatted
24
+ sqlfmt query.sql --check
25
+ ```
26
+
27
+ ## Example
28
+
29
+ Input:
30
+ ```sql
31
+ select id,name from users where active=1 order by name
32
+ ```
33
+
34
+ Output:
35
+ ```sql
36
+ SELECT id,
37
+ name
38
+ FROM users
39
+ WHERE active = 1
40
+ ORDER BY name
41
+ ```
42
+
43
+ ## Features
44
+
45
+ - Uppercases all SQL keywords (`SELECT`, `FROM`, `WHERE`, `JOIN`, `GROUP BY`, …)
46
+ - Puts each clause on its own line
47
+ - Indents subqueries inside parentheses
48
+ - Handles quoted strings and identifiers without corrupting them
49
+ - Preserves `--` line comments and `/* */` block comments
50
+ - `--check` flag for CI pipelines
51
+ - Zero runtime dependencies — pure Node.js
52
+
53
+ ## Supported SQL
54
+
55
+ Works with MySQL, PostgreSQL, SQLite, and most ANSI SQL dialects.
56
+
57
+ Supports: `SELECT`, `FROM`, `WHERE`, `JOIN` variants, `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT`, `OFFSET`, `UNION`, `INSERT`, `UPDATE`, `DELETE`, `CREATE TABLE`, `ALTER TABLE`, `WITH` (CTEs), window functions, `CASE` expressions, and more.
58
+
59
+ ## License
60
+
61
+ MIT
package/index.js ADDED
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ // SQL keywords to uppercase — ordered longest-first to avoid partial matches
8
+ const KEYWORDS = [
9
+ 'SELECT', 'DISTINCT', 'FROM', 'WHERE', 'GROUP BY', 'HAVING', 'ORDER BY',
10
+ 'LIMIT', 'OFFSET', 'INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'FULL JOIN',
11
+ 'FULL OUTER JOIN', 'LEFT OUTER JOIN', 'RIGHT OUTER JOIN', 'CROSS JOIN',
12
+ 'JOIN', 'ON', 'AS', 'AND', 'OR', 'NOT', 'IN', 'EXISTS', 'BETWEEN',
13
+ 'LIKE', 'ILIKE', 'IS NULL', 'IS NOT NULL', 'NULL', 'TRUE', 'FALSE',
14
+ 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'UNION ALL', 'UNION', 'INTERSECT',
15
+ 'EXCEPT', 'INSERT INTO', 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET',
16
+ 'DELETE FROM', 'DELETE', 'CREATE TABLE', 'CREATE INDEX', 'CREATE VIEW',
17
+ 'CREATE', 'DROP TABLE', 'DROP', 'ALTER TABLE', 'ALTER', 'ADD COLUMN',
18
+ 'ADD', 'COLUMN', 'PRIMARY KEY', 'FOREIGN KEY', 'REFERENCES', 'UNIQUE',
19
+ 'DEFAULT', 'NOT NULL', 'AUTO_INCREMENT', 'AUTOINCREMENT', 'SERIAL',
20
+ 'WITH', 'RECURSIVE', 'OVER', 'PARTITION BY', 'ROW_NUMBER', 'RANK',
21
+ 'DENSE_RANK', 'COALESCE', 'NULLIF', 'CAST', 'CONVERT', 'COUNT', 'SUM',
22
+ 'AVG', 'MIN', 'MAX', 'IF', 'IFNULL',
23
+ 'ASC', 'DESC', 'ALL', 'ANY', 'SOME', 'TRUNCATE', 'EXPLAIN', 'ANALYZE',
24
+ 'RETURNING', 'CONFLICT', 'DO NOTHING', 'DO UPDATE',
25
+ ];
26
+
27
+ // Clause-level keywords that each get their own line (top-level only)
28
+ const CLAUSE_KEYWORDS = [
29
+ 'SELECT', 'DISTINCT', 'FROM', 'WHERE', 'GROUP BY', 'HAVING', 'ORDER BY',
30
+ 'LIMIT', 'OFFSET',
31
+ 'INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'FULL JOIN',
32
+ 'FULL OUTER JOIN', 'LEFT OUTER JOIN', 'RIGHT OUTER JOIN', 'CROSS JOIN',
33
+ 'JOIN', 'ON',
34
+ 'UNION ALL', 'UNION', 'INTERSECT', 'EXCEPT',
35
+ 'INSERT INTO', 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET',
36
+ 'DELETE FROM', 'DELETE',
37
+ 'CREATE TABLE', 'CREATE INDEX', 'CREATE VIEW', 'CREATE',
38
+ 'DROP TABLE', 'DROP', 'ALTER TABLE', 'ALTER',
39
+ 'WITH', 'RETURNING', 'EXPLAIN', 'ANALYZE',
40
+ ];
41
+
42
+ // Sort by length descending so multi-word phrases match before single words
43
+ CLAUSE_KEYWORDS.sort((a, b) => b.length - a.length);
44
+ KEYWORDS.sort((a, b) => b.length - a.length);
45
+
46
+ /**
47
+ * Tokenize SQL into an array of tokens: { type, value }
48
+ * Types: string, comment_line, comment_block, paren_open, paren_close,
49
+ * comma, semicolon, whitespace, word
50
+ */
51
+ function tokenize(sql) {
52
+ const tokens = [];
53
+ let i = 0;
54
+ while (i < sql.length) {
55
+ // Single-quoted string
56
+ if (sql[i] === "'") {
57
+ let j = i + 1;
58
+ while (j < sql.length) {
59
+ if (sql[j] === "'" && sql[j + 1] === "'") { j += 2; continue; }
60
+ if (sql[j] === "'") { j++; break; }
61
+ j++;
62
+ }
63
+ tokens.push({ type: 'string', value: sql.slice(i, j) });
64
+ i = j;
65
+ continue;
66
+ }
67
+ // Double-quoted identifier
68
+ if (sql[i] === '"') {
69
+ let j = i + 1;
70
+ while (j < sql.length) {
71
+ if (sql[j] === '"' && sql[j + 1] === '"') { j += 2; continue; }
72
+ if (sql[j] === '"') { j++; break; }
73
+ j++;
74
+ }
75
+ tokens.push({ type: 'string', value: sql.slice(i, j) });
76
+ i = j;
77
+ continue;
78
+ }
79
+ // Backtick identifier
80
+ if (sql[i] === '`') {
81
+ let j = i + 1;
82
+ while (j < sql.length && sql[j] !== '`') j++;
83
+ tokens.push({ type: 'string', value: sql.slice(i, j + 1) });
84
+ i = j + 1;
85
+ continue;
86
+ }
87
+ // Line comment
88
+ if (sql[i] === '-' && sql[i + 1] === '-') {
89
+ let j = i;
90
+ while (j < sql.length && sql[j] !== '\n') j++;
91
+ tokens.push({ type: 'comment_line', value: sql.slice(i, j) });
92
+ i = j;
93
+ continue;
94
+ }
95
+ // Block comment
96
+ if (sql[i] === '/' && sql[i + 1] === '*') {
97
+ let j = i + 2;
98
+ while (j < sql.length && !(sql[j] === '*' && sql[j + 1] === '/')) j++;
99
+ tokens.push({ type: 'comment_block', value: sql.slice(i, j + 2) });
100
+ i = j + 2;
101
+ continue;
102
+ }
103
+ // Parens
104
+ if (sql[i] === '(') { tokens.push({ type: 'paren_open', value: '(' }); i++; continue; }
105
+ if (sql[i] === ')') { tokens.push({ type: 'paren_close', value: ')' }); i++; continue; }
106
+ // Comma
107
+ if (sql[i] === ',') { tokens.push({ type: 'comma', value: ',' }); i++; continue; }
108
+ // Semicolon
109
+ if (sql[i] === ';') { tokens.push({ type: 'semicolon', value: ';' }); i++; continue; }
110
+ // Whitespace
111
+ if (/\s/.test(sql[i])) {
112
+ let j = i;
113
+ while (j < sql.length && /\s/.test(sql[j])) j++;
114
+ tokens.push({ type: 'whitespace', value: sql.slice(i, j) });
115
+ i = j;
116
+ continue;
117
+ }
118
+ // Word / symbol
119
+ let j = i;
120
+ while (j < sql.length && !/[\s(),;'"` ]/.test(sql[j])) j++;
121
+ if (j === i) j++; // consume at least one char
122
+ tokens.push({ type: 'word', value: sql.slice(i, j) });
123
+ i = j;
124
+ }
125
+ return tokens;
126
+ }
127
+
128
+ /**
129
+ * Uppercase SQL keywords in a flat token stream (preserves strings/comments).
130
+ * Returns modified tokens.
131
+ */
132
+ function uppercaseKeywords(tokens) {
133
+ // Build a flat text version of non-string tokens so we can pattern-match
134
+ // multi-word keywords that may be split across token boundaries.
135
+ // Strategy: join adjacent word/whitespace tokens, then do keyword replacement.
136
+ const result = [];
137
+ let i = 0;
138
+ while (i < tokens.length) {
139
+ const tok = tokens[i];
140
+ // Preserve strings and comments verbatim
141
+ if (tok.type === 'string' || tok.type === 'comment_line' || tok.type === 'comment_block') {
142
+ result.push(tok);
143
+ i++;
144
+ continue;
145
+ }
146
+ if (tok.type === 'word') {
147
+ // Try to match a multi-word keyword starting here
148
+ let matched = false;
149
+ for (const kw of KEYWORDS) {
150
+ const parts = kw.split(' ');
151
+ if (parts.length === 1) {
152
+ if (tok.value.toUpperCase() === kw) {
153
+ result.push({ type: 'word', value: kw });
154
+ i++;
155
+ matched = true;
156
+ break;
157
+ }
158
+ } else {
159
+ // Multi-word: gather upcoming word tokens (skip single whitespace between)
160
+ let j = i;
161
+ let p = 0;
162
+ const consumed = [];
163
+ while (p < parts.length && j < tokens.length) {
164
+ // Skip whitespace tokens between words
165
+ if (tokens[j].type === 'whitespace' && p > 0) {
166
+ consumed.push(tokens[j]);
167
+ j++;
168
+ continue;
169
+ }
170
+ if (tokens[j].type === 'word' && tokens[j].value.toUpperCase() === parts[p]) {
171
+ consumed.push(tokens[j]);
172
+ j++;
173
+ p++;
174
+ } else {
175
+ break;
176
+ }
177
+ }
178
+ if (p === parts.length) {
179
+ // Replace with a single uppercase keyword token (spaces normalised)
180
+ result.push({ type: 'word', value: kw });
181
+ i = j;
182
+ matched = true;
183
+ break;
184
+ }
185
+ }
186
+ }
187
+ if (!matched) {
188
+ result.push(tok);
189
+ i++;
190
+ }
191
+ continue;
192
+ }
193
+ result.push(tok);
194
+ i++;
195
+ }
196
+ return result;
197
+ }
198
+
199
+ /**
200
+ * Rebuild a formatted SQL string from the token stream.
201
+ * - Top-level clause keywords start on a new line (depth === 0)
202
+ * - Content inside parentheses is indented by 2 spaces per depth level
203
+ * - Commas at top level produce a newline + indent continuation
204
+ */
205
+ function format(tokens, indentStr = ' ') {
206
+ let depth = 0;
207
+ let output = '';
208
+ let lineIsEmpty = true;
209
+
210
+ // Helper: current indent prefix for depth
211
+ const indent = (d) => indentStr.repeat(d);
212
+
213
+ // We'll do a second pass to insert newlines before clause keywords at depth 0
214
+ // First reconstruct a compact (single-space) version of the token values
215
+ const parts = [];
216
+ let i = 0;
217
+ while (i < tokens.length) {
218
+ const tok = tokens[i];
219
+ if (tok.type === 'whitespace') { parts.push({ type: 'ws' }); i++; continue; }
220
+ parts.push(tok);
221
+ i++;
222
+ }
223
+
224
+ // Now emit formatted output
225
+ let out = '';
226
+ let col = 0; // approximate column position
227
+ let prevNonWs = null;
228
+
229
+ function emit(str) {
230
+ out += str;
231
+ const lastNl = str.lastIndexOf('\n');
232
+ if (lastNl >= 0) col = str.length - lastNl - 1;
233
+ else col += str.length;
234
+ }
235
+
236
+ function newline(d) {
237
+ out += '\n' + indent(d);
238
+ col = indent(d).length;
239
+ }
240
+
241
+ // Trim leading/trailing whitespace tokens
242
+ let start = 0;
243
+ let end = parts.length - 1;
244
+ while (start <= end && parts[start].type === 'ws') start++;
245
+ while (end >= start && parts[end].type === 'ws') end--;
246
+
247
+ for (let idx = start; idx <= end; idx++) {
248
+ const p = parts[idx];
249
+
250
+ if (p.type === 'ws') {
251
+ // We'll handle spacing contextually; skip raw whitespace
252
+ continue;
253
+ }
254
+
255
+ if (p.type === 'paren_open') {
256
+ emit('(');
257
+ depth++;
258
+ // Look ahead: if next non-ws is a clause keyword, newline+indent
259
+ let next = idx + 1;
260
+ while (next <= end && parts[next].type === 'ws') next++;
261
+ if (next <= end && parts[next].type === 'word') {
262
+ const nv = parts[next].value;
263
+ if (CLAUSE_KEYWORDS.includes(nv)) {
264
+ newline(depth);
265
+ }
266
+ }
267
+ prevNonWs = p;
268
+ continue;
269
+ }
270
+
271
+ if (p.type === 'paren_close') {
272
+ depth = Math.max(0, depth - 1);
273
+ // If previous output doesn't end in a newline+indent, just close
274
+ if (out.length > 0 && out[out.length - 1] !== '\n' && col > 0) {
275
+ // Check if the open-paren content had newlines (subquery style)
276
+ const lastNl = out.lastIndexOf('\n');
277
+ if (lastNl >= 0 && out.slice(lastNl + 1).trim() === '') {
278
+ // Already on its own line — just close
279
+ out = out.slice(0, lastNl + 1) + indent(depth) + ')';
280
+ col = indent(depth).length + 1;
281
+ } else {
282
+ emit(')');
283
+ }
284
+ } else {
285
+ emit(')');
286
+ }
287
+ prevNonWs = p;
288
+ continue;
289
+ }
290
+
291
+ if (p.type === 'comma') {
292
+ emit(',');
293
+ if (depth === 0) {
294
+ newline(0);
295
+ // Emit an indent to align with SELECT columns
296
+ emit(' '); // 7 spaces aligns under "SELECT "
297
+ } else {
298
+ // Inside parens: comma + space
299
+ emit(' ');
300
+ }
301
+ prevNonWs = p;
302
+ continue;
303
+ }
304
+
305
+ if (p.type === 'semicolon') {
306
+ emit(';');
307
+ if (idx < end) {
308
+ out += '\n\n';
309
+ col = 0;
310
+ depth = 0;
311
+ }
312
+ prevNonWs = p;
313
+ continue;
314
+ }
315
+
316
+ if (p.type === 'word') {
317
+ // Check if it's a clause keyword at depth 0
318
+ if (depth === 0 && CLAUSE_KEYWORDS.includes(p.value)) {
319
+ const isFirst = (prevNonWs === null);
320
+ if (!isFirst) {
321
+ newline(0);
322
+ }
323
+ emit(p.value);
324
+ emit(' ');
325
+ prevNonWs = p;
326
+ continue;
327
+ }
328
+ // Otherwise emit with space separation
329
+ if (prevNonWs !== null &&
330
+ prevNonWs.type !== 'paren_open' &&
331
+ prevNonWs.type !== 'comma' &&
332
+ !(prevNonWs.type === 'word' && out.endsWith(' '))) {
333
+ // Don't double-space
334
+ if (!out.endsWith(' ') && !out.endsWith('\n')) emit(' ');
335
+ }
336
+ emit(p.value);
337
+ prevNonWs = p;
338
+ continue;
339
+ }
340
+
341
+ // string / comment
342
+ if (prevNonWs !== null &&
343
+ prevNonWs.type !== 'paren_open' &&
344
+ prevNonWs.type !== 'comma' &&
345
+ !out.endsWith(' ') && !out.endsWith('\n')) {
346
+ emit(' ');
347
+ }
348
+ emit(p.value);
349
+ prevNonWs = p;
350
+ }
351
+
352
+ // Trim trailing whitespace on each line
353
+ out = out.split('\n').map(l => l.trimEnd()).join('\n');
354
+ // Ensure single trailing newline
355
+ out = out.trimEnd() + '\n';
356
+ return out;
357
+ }
358
+
359
+ function formatSQL(sql) {
360
+ const tokens = tokenize(sql);
361
+ const upped = uppercaseKeywords(tokens);
362
+ return format(upped);
363
+ }
364
+
365
+ // ─── CLI ───────────────────────────────────────────────────────────────────
366
+
367
+ const args = process.argv.slice(2);
368
+ const checkMode = args.includes('--check');
369
+ const fileArgs = args.filter(a => !a.startsWith('--'));
370
+
371
+ function run(sql) {
372
+ const formatted = formatSQL(sql);
373
+ if (checkMode) {
374
+ const normalIn = formatSQL(sql);
375
+ if (normalIn === formatted) {
376
+ process.stdout.write('SQL is already formatted.\n');
377
+ process.exit(0);
378
+ } else {
379
+ process.stderr.write('SQL is not formatted. Run sqlfmt to fix.\n');
380
+ process.exit(1);
381
+ }
382
+ }
383
+ process.stdout.write(formatted);
384
+ }
385
+
386
+ if (fileArgs.length > 0) {
387
+ // Read from file(s)
388
+ fileArgs.forEach(filePath => {
389
+ const abs = path.resolve(filePath);
390
+ if (!fs.existsSync(abs)) {
391
+ process.stderr.write(`sqlfmt: file not found: ${filePath}\n`);
392
+ process.exit(1);
393
+ }
394
+ const sql = fs.readFileSync(abs, 'utf8');
395
+ run(sql);
396
+ });
397
+ } else {
398
+ // Read from stdin
399
+ let input = '';
400
+ process.stdin.setEncoding('utf8');
401
+ process.stdin.on('data', chunk => { input += chunk; });
402
+ process.stdin.on('end', () => {
403
+ if (!input.trim()) {
404
+ process.stderr.write('sqlfmt: no input. Usage: sqlfmt query.sql or echo "SELECT ..." | sqlfmt\n');
405
+ process.exit(1);
406
+ }
407
+ run(input);
408
+ });
409
+ process.stdin.resume();
410
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "sql-fmt-cli",
3
+ "version": "1.0.0",
4
+ "description": "Format SQL queries from stdin or file — uppercase keywords, one clause per line, indent subqueries",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "sqlfmt": "./index.js"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"select id,name from users where active=1 order by name\" | node index.js"
11
+ },
12
+ "keywords": [
13
+ "sql",
14
+ "format",
15
+ "prettier",
16
+ "cli",
17
+ "database",
18
+ "formatter",
19
+ "sql-formatter",
20
+ "query",
21
+ "mysql",
22
+ "postgres",
23
+ "sqlite"
24
+ ],
25
+ "author": "chengyixu",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/chengyixu/sql-fmt-cli"
30
+ },
31
+ "homepage": "https://github.com/chengyixu/sql-fmt-cli#readme",
32
+ "engines": {
33
+ "node": ">=12.0.0"
34
+ }
35
+ }