Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/minimal-identifier-escape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"rawsql-ts": minor
---

Add `identifierEscapeTarget: "minimal"` to `SqlFormatter` so identifier quotes are removed only when the bare identifier is syntactically valid and semantically safe. The escape symbol remains controlled separately by `identifierEscape` (`quote`, `backtick`, `bracket`, or explicit delimiters). Reserved words, SQL special value expressions such as `current_user` and `current_timestamp`, mixed-case names, and identifiers containing spaces or punctuation remain escaped. Bare SQL special value expressions stay unquoted, while qualified references such as `table.current_user` can still be parsed as column references.
4 changes: 4 additions & 0 deletions packages/core/src/parsers/FullNameParser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Lexeme, TokenType } from "../models/Lexeme";
import { IdentifierString } from "../models/ValueComponent";
import { SQL_SPECIAL_VALUE_KEYWORD_SET } from "../utils/SqlSpecialValueKeywords";
import { SqlTokenizer } from "./SqlTokenizer";

/**
Expand Down Expand Up @@ -105,6 +106,9 @@ export class FullNameParser {
} else if (lexemes[idx].type & TokenType.Type) {
identifiers.push(lexemes[idx].value);
idx++;
} else if ((lexemes[idx].type & TokenType.Literal) && SQL_SPECIAL_VALUE_KEYWORD_SET.has(lexemes[idx].value.toLowerCase())) {
identifiers.push(lexemes[idx].value);
idx++;
} else if (
(lexemes[idx].type & TokenType.Command) &&
POSTGRESQL_COMMAND_KEYWORDS_ALLOWED_AS_IDENTIFIER.has(lexemes[idx].value.toLowerCase())
Expand Down
91 changes: 87 additions & 4 deletions packages/core/src/parsers/IdentifierDecorator.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,98 @@
import { SQL_SPECIAL_VALUE_KEYWORDS } from "../utils/SqlSpecialValueKeywords";
import { TokenType } from "../models/Lexeme";
import { SqlTokenizer } from "./SqlTokenizer";

export class IdentifierDecorator {
start: string;
end: string;
target: 'all' | 'minimal';

constructor(identifierEscape?: { start?: string; end?: string }) {
constructor(identifierEscape?: { start?: string; end?: string; target?: 'all' | 'minimal' }) {
this.start = identifierEscape?.start ?? '"';
this.end = identifierEscape?.end ?? '"';
this.target = identifierEscape?.target ?? 'all';
}

decorate(text: string): string {
// override
text = this.start + text + this.end;
return text;
if (this.target === 'minimal' && this.canRenderBare(text)) {
return text;
}
return this.start + this.escapeIdentifierText(text) + this.end;
}

private canRenderBare(text: string): boolean {
return /^[a-z_][a-z0-9_]*$/.test(text) &&
!UNSAFE_BARE_IDENTIFIERS.has(text) &&
this.isPlainIdentifierToken(text);
}

private isPlainIdentifierToken(text: string): boolean {
const lexemes = new SqlTokenizer(text).readLexmes();
return lexemes.length === 1 && lexemes[0].type === TokenType.Identifier && lexemes[0].value === text;
}

private escapeIdentifierText(text: string): string {
if (!this.end) {
return text;
}
return text.split(this.end).join(this.end + this.end);
}
}

const UNSAFE_BARE_IDENTIFIERS = new Set([
// Core SQL syntax and literals.
'all',
'and',
'any',
'as',
'between',
'by',
'case',
'cross',
'delete',
'distinct',
'else',
'end',
'except',
'exists',
'false',
'fetch',
'for',
'from',
'full',
'group',
'having',
'in',
'inner',
'insert',
'intersect',
'into',
'is',
'join',
'left',
'like',
'limit',
'not',
'null',
'offset',
'on',
'or',
'order',
'outer',
'right',
'select',
'set',
'table',
'then',
'true',
'union',
'update',
'using',
'values',
'when',
'where',
'with',

// SQL value keywords / special bare expressions.
...SQL_SPECIAL_VALUE_KEYWORDS
]);
6 changes: 4 additions & 2 deletions packages/core/src/parsers/SqlPrintTokenParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export interface FormatterConfig {
identifierEscape?: {
start: string;
end: string;
target?: 'all' | 'minimal';
};
parameterSymbol?: string | { start: string; end: string };
/**
Expand Down Expand Up @@ -257,7 +258,7 @@ export class SqlPrintTokenParser implements SqlComponentVisitor<SqlPrintToken> {

constructor(options?: {
preset?: FormatterConfig,
identifierEscape?: { start: string; end: string },
identifierEscape?: { start: string; end: string; target?: 'all' | 'minimal' },
parameterSymbol?: string | { start: string; end: string },
parameterStyle?: 'anonymous' | 'indexed' | 'named',
castStyle?: CastStyle,
Expand All @@ -277,7 +278,8 @@ export class SqlPrintTokenParser implements SqlComponentVisitor<SqlPrintToken> {

this.identifierDecorator = new IdentifierDecorator({
start: options?.identifierEscape?.start ?? '"',
end: options?.identifierEscape?.end ?? '"'
end: options?.identifierEscape?.end ?? '"',
target: options?.identifierEscape?.target
});

this.castStyle = options?.castStyle ?? 'standard';
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/parsers/ValueParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { FunctionExpressionParser } from "./FunctionExpressionParser";
import { FullNameParser } from "./FullNameParser";
import { ParseError } from "./ParseError";
import { OperatorPrecedence } from "../utils/OperatorPrecedence";
import { SQL_SPECIAL_VALUE_KEYWORD_SET } from "../utils/SqlSpecialValueKeywords";

export class ValueParser {
// Parse SQL string to AST (was: parse)
Expand Down Expand Up @@ -263,6 +264,11 @@ export class ValueParser {
const value = new ColumnReference(namespaces, name);
this.transferPositionedComments(current, value);
return { value, newIndex };
} else if ((current.type & TokenType.Literal) && this.isQualifiedSpecialValueIdentifier(lexemes, idx)) {
const { namespaces, name, newIndex } = FullNameParser.parseFromLexeme(lexemes, idx);
const value = new ColumnReference(namespaces, name);
this.transferPositionedComments(current, value);
return { value, newIndex };
} else if (current.type & TokenType.Literal) {
const result = LiteralParser.parseFromLexeme(lexemes, idx);
this.transferPositionedComments(current, result.value);
Expand Down Expand Up @@ -324,6 +330,12 @@ export class ValueParser {
throw ParseError.fromUnparsedLexemes(lexemes, idx, `[ValueParser] Invalid lexeme.`);
}

private static isQualifiedSpecialValueIdentifier(lexemes: Lexeme[], index: number): boolean {
return SQL_SPECIAL_VALUE_KEYWORD_SET.has(lexemes[index].value.toLowerCase()) &&
index + 1 < lexemes.length &&
(lexemes[index + 1].type & TokenType.Dot) !== 0;
}

public static parseArgument(openToken: TokenType, closeToken: TokenType, lexemes: Lexeme[], index: number): { value: ValueComponent; newIndex: number } {
let idx = index;
const args: ValueComponent[] = [];
Expand Down
7 changes: 2 additions & 5 deletions packages/core/src/tokenReaders/LiteralTokenReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CharLookupTable } from '../utils/charLookupTable';
import { looksLikeSqlServerMoneyLiteral } from './SqlServerMoneyLiteralDetector';
import { KeywordParser } from '../parsers/KeywordParser';
import { KeywordTrie } from '../models/KeywordTrie';
import { SQL_SPECIAL_VALUE_KEYWORDS } from '../utils/SqlSpecialValueKeywords';

/**
* Reads SQL literal tokens (numbers, strings)
Expand All @@ -13,11 +14,7 @@ const keywords = [
["null"],
["true"],
["false"],
["current_date"],
["current_time"],
["current_timestamp"],
["localtime"],
["localtimestamp"],
...SQL_SPECIAL_VALUE_KEYWORDS.map(keyword => [keyword]),
["unbounded"],
["normalized"],
["nfc", "normalized"],
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/transformers/FormatOptionResolver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { IndentCharLogicalName, IndentCharOption, NewlineLogicalName, NewlineOption } from './LinePrinter';

export type IdentifierEscapeName = 'quote' | 'backtick' | 'bracket' | 'none';
export type IdentifierEscapeSymbol = 'quote' | 'backtick' | 'bracket';
export type IdentifierEscapeTarget = 'all' | 'minimal';
export type IdentifierEscapeName = IdentifierEscapeSymbol | 'none';
export type ResolvedIdentifierEscapeOption = { start: string; end: string; target: IdentifierEscapeTarget };
export type IdentifierEscapeOption = IdentifierEscapeName | { start: string; end: string };

const INDENT_CHAR_MAP = {
Expand Down Expand Up @@ -55,7 +58,10 @@ export function resolveNewlineOption(option?: NewlineOption): NewlineOption | un
return option;
}

export function resolveIdentifierEscapeOption(option?: IdentifierEscapeOption): { start: string; end: string } | undefined {
export function resolveIdentifierEscapeOption(
option?: IdentifierEscapeOption,
target: IdentifierEscapeTarget = 'all'
): ResolvedIdentifierEscapeOption | undefined {
if (option === undefined) {
// Allow undefined so presets can supply defaults.
return undefined;
Expand All @@ -70,12 +76,16 @@ export function resolveIdentifierEscapeOption(option?: IdentifierEscapeOption):

// Spread into a new object to avoid mutating shared map entries.
const mapped = IDENTIFIER_ESCAPE_MAP[normalized];
return { start: mapped.start, end: mapped.end };
return {
start: mapped.start,
end: mapped.end,
target
};
}

const start = option.start ?? '';
const end = option.end ?? '';

// Return a copy so callers do not mutate the input reference.
return { start, end };
return { start, end, target };
}
9 changes: 7 additions & 2 deletions packages/core/src/transformers/SqlFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { SqlPrintTokenParser, FormatterConfig, PRESETS, CastStyle, ConstraintSty
import { SqlPrinter, CommaBreakStyle, AndBreakStyle, OrBreakStyle } from './SqlPrinter';
import { CommentExportMode } from '../types/Formatting';
import { IndentCharOption, NewlineOption } from './LinePrinter'; // Import types for compatibility
import { IdentifierEscapeOption, resolveIdentifierEscapeOption } from './FormatOptionResolver';
import { IdentifierEscapeOption, IdentifierEscapeTarget, resolveIdentifierEscapeOption } from './FormatOptionResolver';
import { SelectQuery } from '../models/SelectQuery';
import { SqlComponent } from '../models/SqlComponent';

Expand Down Expand Up @@ -99,6 +99,8 @@ export interface SqlFormatterOptions extends BaseFormattingOptions {
preset?: PresetName;
/** Identifier escape style (logical name like 'quote' or explicit delimiters) */
identifierEscape?: IdentifierEscapeOption;
/** Identifier escape target: all identifiers or only identifiers that need escaping */
identifierEscapeTarget?: IdentifierEscapeTarget;
/** Parameter symbol configuration for SQL parameters */
parameterSymbol?: string | { start: string; end: string };
/** Style for parameter formatting */
Expand Down Expand Up @@ -133,7 +135,10 @@ export class SqlFormatter {
}

// Normalize identifier escape names into actual delimiter pairs before configuring the parser.
const resolvedIdentifierEscape = resolveIdentifierEscapeOption(options.identifierEscape ?? presetConfig?.identifierEscape);
const resolvedIdentifierEscape = resolveIdentifierEscapeOption(
options.identifierEscape ?? presetConfig?.identifierEscape,
options.identifierEscapeTarget ?? 'all'
);

const parserOptions = {
...presetConfig, // Apply preset configuration
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/utils/SqlSpecialValueKeywords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const SQL_SPECIAL_VALUE_KEYWORDS = [
'current_catalog',
'current_date',
'current_role',
'current_schema',
'current_time',
'current_timestamp',
'current_user',
'localtime',
'localtimestamp',
'session_user',
'user'
] as const;

export const SQL_SPECIAL_VALUE_KEYWORD_SET = new Set<string>(SQL_SPECIAL_VALUE_KEYWORDS);
13 changes: 8 additions & 5 deletions packages/core/tests/transformers/FormatOptionResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ describe('FormatOptionResolver', () => {
});

it('maps identifier escape logical names to delimiter pairs', () => {
expect(resolveIdentifierEscapeOption('quote')).toEqual({ start: '"', end: '"' });
expect(resolveIdentifierEscapeOption('backtick')).toEqual({ start: '`', end: '`' });
expect(resolveIdentifierEscapeOption('bracket')).toEqual({ start: '[', end: ']' });
expect(resolveIdentifierEscapeOption('none')).toEqual({ start: '', end: '' });
expect(resolveIdentifierEscapeOption('quote')).toEqual({ start: '"', end: '"', target: 'all' });
expect(resolveIdentifierEscapeOption('backtick')).toEqual({ start: '`', end: '`', target: 'all' });
expect(resolveIdentifierEscapeOption('bracket')).toEqual({ start: '[', end: ']', target: 'all' });
expect(resolveIdentifierEscapeOption('none')).toEqual({ start: '', end: '', target: 'all' });
expect(resolveIdentifierEscapeOption('quote', 'minimal')).toEqual({ start: '"', end: '"', target: 'minimal' });
expect(resolveIdentifierEscapeOption('backtick', 'minimal')).toEqual({ start: '`', end: '`', target: 'minimal' });
expect(resolveIdentifierEscapeOption('none', 'minimal')).toEqual({ start: '', end: '', target: 'minimal' });
});

it('returns explicit identifier delimiters unchanged', () => {
const custom = { start: '<<', end: '>>' };
expect(resolveIdentifierEscapeOption(custom)).toEqual(custom);
expect(resolveIdentifierEscapeOption(custom)).toEqual({ ...custom, target: 'all' });
});

it('throws on unknown identifier escape alias', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { SelectQueryParser } from '../../src/parsers/SelectQueryParser';
import { SqlFormatter } from '../../src/transformers/SqlFormatter';

const formatMinimal = (sql: string): string => {
const query = SelectQueryParser.parse(sql);
return new SqlFormatter({
preset: 'postgres',
identifierEscapeTarget: 'minimal'
}).format(query).formattedSql;
};

describe('SqlFormatter identifierEscape minimal', () => {
it('removes quotes from safe lowercase identifiers', () => {
expect(formatMinimal('select "email" from "public"."users"')).toBe('select email from public.users');
});

it('keeps quotes when unquoting would produce SQL special expressions', () => {
expect(formatMinimal('select "current_user", "current_timestamp" from "users"')).toBe(
'select "current_user", "current_timestamp" from users'
);
});

it('keeps quotes for system information identifiers that would become special expressions', () => {
expect(formatMinimal('select "current_catalog", "current_role", "current_schema", "session_user", "user" from "users"')).toBe(
'select "current_catalog", "current_role", "current_schema", "session_user", "user" from users'
);
});

it('does not quote bare SQL special expressions parsed as values', () => {
expect(formatMinimal('select current_timestamp, current_user, session_user, user from users')).toBe(
'select current_timestamp, current_user, session_user, user from users'
);
});

it('treats qualified SQL special value words as identifiers', () => {
expect(formatMinimal('select users.current_user, users.current_timestamp from current_user')).toBe(
'select users."current_user", users."current_timestamp" from "current_user"'
);
});

it('keeps quotes for reserved words, mixed case, and invalid bare identifier shapes', () => {
expect(formatMinimal('select "select", "UserName", "user-id", "test text" from "table"')).toBe(
'select "select", "UserName", "user-id", "test text" from "table"'
);
});

it('keeps quotes for existing tokenizer keywords not listed as core SQL syntax', () => {
expect(formatMinimal('select "lateral", "window", "key", "date" from "users"')).toBe(
'select "lateral", "window", key, "date" from users'
);
});

it('applies the same minimal rule to each qualified name part', () => {
expect(formatMinimal('select "users"."email", "users"."current_timestamp" from "users"')).toBe(
'select users.email, users."current_timestamp" from users'
);
});

it('uses the preset symbol when minimal quoting is required', () => {
const query = SelectQueryParser.parse('select "current_timestamp" from "users"');
const formattedSql = new SqlFormatter({
preset: 'mysql',
identifierEscapeTarget: 'minimal'
}).format(query).formattedSql;

expect(formattedSql).toBe('select `current_timestamp` from users');
});

it('combines minimal target with an explicit escape symbol', () => {
const query = SelectQueryParser.parse('select "current_timestamp", "email" from "users"');
const formattedSql = new SqlFormatter({
identifierEscape: 'backtick',
identifierEscapeTarget: 'minimal'
}).format(query).formattedSql;

expect(formattedSql).toBe('select `current_timestamp`, email from users');
});
});
Loading