Skip to content
Open
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
20 changes: 16 additions & 4 deletions packages/3-targets/3-targets/postgres/src/core/sql-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,25 @@ import { postgresError } from './errors';

const MAX_IDENTIFIER_LENGTH = 63;

const utf8 = new TextEncoder();

/**
* UTF-8 byte length — the unit PostgreSQL measures identifiers and enum labels
* in. `NAMEDATALEN - 1` is 63 *bytes*, so a name written in non-ASCII
* characters can sit well under 63 characters and still overrun. Both length
* checks in this module read through here so they cannot drift apart.
*/
function byteLength(value: string): number {
return utf8.encode(value).length;
}

/**
* Validates and quotes a PostgreSQL identifier (table, column, type, schema names).
*
* Security validations:
* - Rejects null bytes which could cause truncation or unexpected behavior
* - Rejects empty identifiers
* - Warns on identifiers exceeding PostgreSQL's 63-character limit
* - Warns on identifiers exceeding PostgreSQL's 63-byte limit
*
* @throws `CONTRACT.IDENTIFIER_INVALID` structured error If the identifier contains null bytes or is empty
*/
Expand All @@ -35,9 +47,9 @@ export function quoteIdentifier(identifier: string): string {
meta: { value: identifier.replace(/\0/g, '\\0'), context: 'identifier' },
});
}
if (identifier.length > MAX_IDENTIFIER_LENGTH) {
if (byteLength(identifier) > MAX_IDENTIFIER_LENGTH) {
console.warn(
`Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's ${MAX_IDENTIFIER_LENGTH}-character limit and will be truncated`,
`Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's ${MAX_IDENTIFIER_LENGTH}-byte limit and will be truncated`,
);
}
return `"${identifier.replace(/"/g, '""')}"`;
Expand Down Expand Up @@ -95,7 +107,7 @@ export function quoteQualifiedName(name: string): string {
* @throws `CONTRACT.IDENTIFIER_INVALID` structured error If the value exceeds the maximum length
*/
export function validateEnumValueLength(value: string, enumTypeName: string): void {
if (new TextEncoder().encode(value).length > MAX_IDENTIFIER_LENGTH) {
if (byteLength(value) > MAX_IDENTIFIER_LENGTH) {
throw postgresError(
'CONTRACT.IDENTIFIER_INVALID',
`Enum value "${value.slice(0, 20)}..." for type "${enumTypeName}" exceeds PostgreSQL's ` +
Expand Down
35 changes: 34 additions & 1 deletion packages/3-targets/3-targets/postgres/test/sql-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,44 @@ describe('quoteIdentifier', () => {

expect(result).toBe(`"${identifier}"`);
expect(warnSpy).toHaveBeenCalledWith(
`Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's 63-character limit and will be truncated`,
`Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's 63-byte limit and will be truncated`,
);

warnSpy.mockRestore();
});

it('warns for a multibyte identifier over 63 bytes but under 63 characters', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// A Cyrillic column name: 50 characters, 96 UTF-8 bytes. Postgres measures
// the byte length, so it stores this name truncated to 63 bytes — the
// declared object can then never be matched against the live one.
const identifier = 'электронная_почта_адрес_подтверждена_пользователем';

const result = quoteIdentifier(identifier);

expect(identifier.length).toBeLessThanOrEqual(63);
expect(new TextEncoder().encode(identifier).length).toBeGreaterThan(63);
expect(result).toBe(`"${identifier}"`);
expect(warnSpy).toHaveBeenCalledWith(
`Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's 63-byte limit and will be truncated`,
);

warnSpy.mockRestore();
});

it('stays silent for a multibyte identifier that is exactly 63 bytes', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// '€' (U+20AC) is 3 UTF-8 bytes: 21 characters = 63 bytes, exactly at the limit.
const identifier = '€'.repeat(21);

const result = quoteIdentifier(identifier);

expect(new TextEncoder().encode(identifier).length).toBe(63);
expect(result).toBe(`"${identifier}"`);
expect(warnSpy).not.toHaveBeenCalled();

warnSpy.mockRestore();
});
});

describe('escapeLiteral', () => {
Expand Down