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', () => {