From c0462883178e44c099201fb5046e84cb86466f4b Mon Sep 17 00:00:00 2001 From: Mahathir Mohammad Shuvo Date: Tue, 25 Aug 2026 19:47:24 +0600 Subject: [PATCH] fix(target-postgres): measure identifier length in bytes, not characters PostgreSQL truncates identifiers at NAMEDATALEN - 1, which is 63 *bytes*. quoteIdentifier compared identifier.length, so a name written in non-ASCII characters could sit well under 63 characters and still overrun: a 50-character Cyrillic column name is 96 UTF-8 bytes, and the warning never fired. validateEnumValueLength in the same module already measured bytes via TextEncoder. Both checks now share one byteLength helper so they cannot drift apart again, and the warning text says "byte" rather than "character". Signed-off-by: Mahathir Mohammad Shuvo --- .../3-targets/postgres/src/core/sql-utils.ts | 20 ++++++++--- .../3-targets/postgres/test/sql-utils.test.ts | 35 ++++++++++++++++++- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts b/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts index 1673f289a3fb..9ffca6a41bd0 100644 --- a/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts +++ b/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts @@ -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 */ @@ -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, '""')}"`; @@ -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 ` + diff --git a/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts b/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts index 07380183b169..61e02cea6fb4 100644 --- a/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts +++ b/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts @@ -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', () => {