From eb9ffcf19c923b5d7d80213de0f6e0d674a6c245 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 31 Aug 2026 14:50:29 +0200 Subject: [PATCH 1/4] fix(postgrest-typegen): escape identifiers in the Python generator Enum labels and column aliases were interpolated into the generated Python source unescaped, so a name containing a double quote, backslash or newline produced invalid Python and could inject text into the generated module. Emit them through JSON string escaping, which is a subset of Python's string literal escaping. Ported from supabase/postgres-meta#1082 --- .../src/generation/python.ts | 18 ++++++++++-- .../test/generation/python.test.ts | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/postgrest-typegen/src/generation/python.ts b/packages/postgrest-typegen/src/generation/python.ts index 8114e63..4e5d24a 100644 --- a/packages/postgrest-typegen/src/generation/python.ts +++ b/packages/postgrest-typegen/src/generation/python.ts @@ -229,7 +229,9 @@ class PythonEnum implements Serializable { this.variants = type.enums; } serialize(): string { - const variants = this.variants.map((item) => `"${item}"`).join(", "); + const variants = this.variants + .map((item) => escapePythonString(item)) + .join(", "); return `${this.name}: TypeAlias = Literal[${variants}]`; } } @@ -273,7 +275,7 @@ class PythonBaseModelAttr implements Serializable { const py_type = this.nullable ? `Optional[${this.py_type.serialize()}]` : this.py_type.serialize(); - return ` ${this.name}: ${py_type} = Field(alias="${this.pg_name}")`; + return ` ${this.name}: ${py_type} = Field(alias=${escapePythonString(this.pg_name)})`; } } @@ -326,7 +328,7 @@ class PythonTypedDictAttr implements Serializable { const py_type = this.nullable ? `Optional[${this.py_type.serialize()}]` : this.py_type.serialize(); - const annotation = `Annotated[${py_type}, Field(alias="${this.pg_name}")]`; + const annotation = `Annotated[${py_type}, Field(alias=${escapePythonString(this.pg_name)})]`; const rhs = this.not_required ? `NotRequired[${annotation}]` : annotation; return ` ${this.name}: ${rhs}`; } @@ -367,6 +369,16 @@ function concatLines(items: Serializable[]): string { return items.map((item) => item.serialize()).join("\n\n"); } +/** + * Emits a Postgres name as a double-quoted Python string literal. JSON string + * escaping is a subset of Python's (`\"`, `\\`, `\n`, `\uXXXX`), so + * `JSON.stringify` yields a literal Python parses to the exact original name + * and keeps quotes, backslashes and newlines from breaking out of it. + */ +function escapePythonString(value: string): string { + return JSON.stringify(value); +} + const PY_TYPE_MAP: Record = { // Bool bool: "bool", diff --git a/packages/postgrest-typegen/test/generation/python.test.ts b/packages/postgrest-typegen/test/generation/python.test.ts index 1d717a4..101d9dc 100644 --- a/packages/postgrest-typegen/test/generation/python.test.ts +++ b/packages/postgrest-typegen/test/generation/python.test.ts @@ -193,6 +193,35 @@ describe("python typegen", () => { `); }); + test("enum labels with quotes, backslashes and newlines are escaped", () => { + const result = generatePython( + buildMetadata({ + types: [ + { ...userStatusEnum, enums: ['a";b', "back\\slash", "new\nline"] }, + textType, + ], + }), + ); + + expect(result).toContain( + 'Literal["a\\";b", "back\\\\slash", "new\\nline"]', + ); + expect(result).not.toContain('Literal["a";b"'); + }); + + test("column names with quotes are escaped in Field aliases", () => { + const result = generatePython( + buildMetadata({ + tables: [baseTable()], + columns: [baseColumn({ name: 'quo"ted' })], + }), + ); + + expect(result).toContain('quo_ted: str = Field(alias="quo\\"ted")'); + expect(result).toContain('Annotated[str, Field(alias="quo\\"ted")]'); + expect(result).not.toContain('alias="quo"ted"'); + }); + test("array column resolves to List[...] and multi-word names are normalized", () => { const result = generatePython( buildMetadata({ From a717f59ea24e124006bffe761add2dc878e171de Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 31 Aug 2026 14:51:23 +0200 Subject: [PATCH 2/4] fix(postgrest-typegen): import NotRequired and TypeAlias from typing_extensions NotRequired only exists in typing from Python 3.11 and TypeAlias from 3.10, so the generated module failed to import on Python 3.9 and 3.10. typing_extensions ships them for all supported versions and is already a required dependency of pydantic, so the import is always available. Ported from supabase/postgres-meta#1094 --- .../src/generation/python.ts | 3 +-- .../test/generation/python.test.ts | 24 ++++++++++++------- .../test/parity/expected/python.txt | 3 +-- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/postgrest-typegen/src/generation/python.ts b/packages/postgrest-typegen/src/generation/python.ts index 4e5d24a..cfd3244 100644 --- a/packages/postgrest-typegen/src/generation/python.ts +++ b/packages/postgrest-typegen/src/generation/python.ts @@ -48,11 +48,10 @@ from typing import ( Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) +from typing_extensions import NotRequired, TypeAlias from pydantic import BaseModel, Field, Json diff --git a/packages/postgrest-typegen/test/generation/python.test.ts b/packages/postgrest-typegen/test/generation/python.test.ts index 101d9dc..74d056d 100644 --- a/packages/postgrest-typegen/test/generation/python.test.ts +++ b/packages/postgrest-typegen/test/generation/python.test.ts @@ -63,11 +63,10 @@ describe("python typegen", () => { Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) + from typing_extensions import NotRequired, TypeAlias from pydantic import BaseModel, Field, Json @@ -127,11 +126,10 @@ describe("python typegen", () => { Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) + from typing_extensions import NotRequired, TypeAlias from pydantic import BaseModel, Field, Json @@ -171,11 +169,10 @@ describe("python typegen", () => { Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) + from typing_extensions import NotRequired, TypeAlias from pydantic import BaseModel, Field, Json @@ -193,6 +190,18 @@ describe("python typegen", () => { `); }); + test("NotRequired and TypeAlias come from typing_extensions for Python 3.9 support", () => { + const result = generatePython(buildMetadata()); + const typingImport = result.match(/from typing import \(([\s\S]*?)\)/)?.[1]; + + expect(typingImport).toBeDefined(); + expect(typingImport).not.toContain("NotRequired"); + expect(typingImport).not.toContain("TypeAlias"); + expect(result).toContain( + "from typing_extensions import NotRequired, TypeAlias", + ); + }); + test("enum labels with quotes, backslashes and newlines are escaped", () => { const result = generatePython( buildMetadata({ @@ -248,11 +257,10 @@ describe("python typegen", () => { Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) + from typing_extensions import NotRequired, TypeAlias from pydantic import BaseModel, Field, Json diff --git a/packages/postgrest-typegen/test/parity/expected/python.txt b/packages/postgrest-typegen/test/parity/expected/python.txt index 716adeb..9d88f25 100644 --- a/packages/postgrest-typegen/test/parity/expected/python.txt +++ b/packages/postgrest-typegen/test/parity/expected/python.txt @@ -7,11 +7,10 @@ from typing import ( Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) +from typing_extensions import NotRequired, TypeAlias from pydantic import BaseModel, Field, Json From d86db014600ea46c0138ff9ee556d2aaacbe8840 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 31 Aug 2026 14:52:06 +0200 Subject: [PATCH 3/4] fix(postgrest-typegen): map json and jsonb columns to JsonValue in Python pydantic's Json[Any] validates a JSON string and deserializes it, but PostgREST returns json and jsonb columns already deserialized, so every generated model with a JSON column failed model_validate. JsonValue is pydantic's type for an already parsed JSON value. Ported from supabase/postgres-meta#1129 --- .../src/generation/python.ts | 10 +++--- .../test/generation/python.test.ts | 34 ++++++++++++++++--- .../test/parity/expected/python.txt | 32 ++++++++--------- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/packages/postgrest-typegen/src/generation/python.ts b/packages/postgrest-typegen/src/generation/python.ts index cfd3244..249342d 100644 --- a/packages/postgrest-typegen/src/generation/python.ts +++ b/packages/postgrest-typegen/src/generation/python.ts @@ -53,7 +53,7 @@ from typing import ( ) from typing_extensions import NotRequired, TypeAlias -from pydantic import BaseModel, Field, Json +from pydantic import BaseModel, Field, JsonValue ${concatLines(Object.values(ctx.user_enums))} @@ -406,9 +406,11 @@ const PY_TYPE_MAP: Record = { vector: "list[Any]", interval: "str", - // JSON - json: "Json[Any]", - jsonb: "Json[Any]", + // JSON. PostgREST returns these columns already deserialized, so the + // generated models must accept parsed values (JsonValue), not JSON strings + // (which is what pydantic's Json[...] validates and parses). + json: "JsonValue", + jsonb: "JsonValue", // Range types (can be adjusted to more complex types if needed) int4range: "str", diff --git a/packages/postgrest-typegen/test/generation/python.test.ts b/packages/postgrest-typegen/test/generation/python.test.ts index 74d056d..046ec51 100644 --- a/packages/postgrest-typegen/test/generation/python.test.ts +++ b/packages/postgrest-typegen/test/generation/python.test.ts @@ -68,7 +68,7 @@ describe("python typegen", () => { ) from typing_extensions import NotRequired, TypeAlias - from pydantic import BaseModel, Field, Json + from pydantic import BaseModel, Field, JsonValue PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] @@ -131,7 +131,7 @@ describe("python typegen", () => { ) from typing_extensions import NotRequired, TypeAlias - from pydantic import BaseModel, Field, Json + from pydantic import BaseModel, Field, JsonValue PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] @@ -174,7 +174,7 @@ describe("python typegen", () => { ) from typing_extensions import NotRequired, TypeAlias - from pydantic import BaseModel, Field, Json + from pydantic import BaseModel, Field, JsonValue PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] @@ -202,6 +202,32 @@ describe("python typegen", () => { ); }); + test("json and jsonb columns accept deserialized values via JsonValue", () => { + const result = generatePython( + buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ name: "payload", format: "json", ordinal_position: 1 }), + baseColumn({ + name: "settings", + format: "jsonb", + is_nullable: true, + ordinal_position: 2, + }), + ], + }), + ); + + expect(result).toContain( + "from pydantic import BaseModel, Field, JsonValue", + ); + expect(result).toContain('payload: JsonValue = Field(alias="payload")'); + expect(result).toContain( + 'settings: Optional[JsonValue] = Field(alias="settings")', + ); + expect(result).not.toContain("Json[Any]"); + }); + test("enum labels with quotes, backslashes and newlines are escaped", () => { const result = generatePython( buildMetadata({ @@ -262,7 +288,7 @@ describe("python typegen", () => { ) from typing_extensions import NotRequired, TypeAlias - from pydantic import BaseModel, Field, Json + from pydantic import BaseModel, Field, JsonValue PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] diff --git a/packages/postgrest-typegen/test/parity/expected/python.txt b/packages/postgrest-typegen/test/parity/expected/python.txt index 9d88f25..8a9fd05 100644 --- a/packages/postgrest-typegen/test/parity/expected/python.txt +++ b/packages/postgrest-typegen/test/parity/expected/python.txt @@ -12,7 +12,7 @@ from typing import ( ) from typing_extensions import NotRequired, TypeAlias -from pydantic import BaseModel, Field, Json +from pydantic import BaseModel, Field, JsonValue PublicMemeStatus: TypeAlias = Literal["new", "old", "retired"] @@ -41,55 +41,55 @@ class PublicEmptyUpdate(TypedDict): class PublicEvents(BaseModel): created_at: datetime.datetime = Field(alias="created_at") - data: Optional[Json[Any]] = Field(alias="data") + data: Optional[JsonValue] = Field(alias="data") event_type: Optional[str] = Field(alias="event_type") id: int = Field(alias="id") class PublicEventsInsert(TypedDict): created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] - data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + data: NotRequired[Annotated[Optional[JsonValue], Field(alias="data")]] event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] id: NotRequired[Annotated[int, Field(alias="id")]] class PublicEventsUpdate(TypedDict): created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] - data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + data: NotRequired[Annotated[Optional[JsonValue], Field(alias="data")]] event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] id: NotRequired[Annotated[int, Field(alias="id")]] class PublicEvents2024(BaseModel): created_at: datetime.datetime = Field(alias="created_at") - data: Optional[Json[Any]] = Field(alias="data") + data: Optional[JsonValue] = Field(alias="data") event_type: Optional[str] = Field(alias="event_type") id: int = Field(alias="id") class PublicEvents2024Insert(TypedDict): created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] - data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + data: NotRequired[Annotated[Optional[JsonValue], Field(alias="data")]] event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] id: Annotated[int, Field(alias="id")] class PublicEvents2024Update(TypedDict): created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] - data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + data: NotRequired[Annotated[Optional[JsonValue], Field(alias="data")]] event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] id: NotRequired[Annotated[int, Field(alias="id")]] class PublicEvents2025(BaseModel): created_at: datetime.datetime = Field(alias="created_at") - data: Optional[Json[Any]] = Field(alias="data") + data: Optional[JsonValue] = Field(alias="data") event_type: Optional[str] = Field(alias="event_type") id: int = Field(alias="id") class PublicEvents2025Insert(TypedDict): created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] - data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + data: NotRequired[Annotated[Optional[JsonValue], Field(alias="data")]] event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] id: Annotated[int, Field(alias="id")] class PublicEvents2025Update(TypedDict): created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] - data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + data: NotRequired[Annotated[Optional[JsonValue], Field(alias="data")]] event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] id: NotRequired[Annotated[int, Field(alias="id")]] @@ -112,7 +112,7 @@ class PublicMemes(BaseModel): category: Optional[int] = Field(alias="category") created_at: datetime.datetime = Field(alias="created_at") id: int = Field(alias="id") - metadata: Optional[Json[Any]] = Field(alias="metadata") + metadata: Optional[JsonValue] = Field(alias="metadata") name: str = Field(alias="name") status: Optional[PublicMemeStatus] = Field(alias="status") @@ -120,7 +120,7 @@ class PublicMemesInsert(TypedDict): category: NotRequired[Annotated[Optional[int], Field(alias="category")]] created_at: Annotated[datetime.datetime, Field(alias="created_at")] id: NotRequired[Annotated[int, Field(alias="id")]] - metadata: NotRequired[Annotated[Optional[Json[Any]], Field(alias="metadata")]] + metadata: NotRequired[Annotated[Optional[JsonValue], Field(alias="metadata")]] name: Annotated[str, Field(alias="name")] status: NotRequired[Annotated[Optional[PublicMemeStatus], Field(alias="status")]] @@ -128,7 +128,7 @@ class PublicMemesUpdate(TypedDict): category: NotRequired[Annotated[Optional[int], Field(alias="category")]] created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] id: NotRequired[Annotated[int, Field(alias="id")]] - metadata: NotRequired[Annotated[Optional[Json[Any]], Field(alias="metadata")]] + metadata: NotRequired[Annotated[Optional[JsonValue], Field(alias="metadata")]] name: NotRequired[Annotated[str, Field(alias="name")]] status: NotRequired[Annotated[Optional[PublicMemeStatus], Field(alias="status")]] @@ -207,19 +207,19 @@ class PublicUsersUpdate(TypedDict): class PublicUsersAudit(BaseModel): created_at: Optional[datetime.datetime] = Field(alias="created_at") id: int = Field(alias="id") - previous_value: Optional[Json[Any]] = Field(alias="previous_value") + previous_value: Optional[JsonValue] = Field(alias="previous_value") user_id: Optional[int] = Field(alias="user_id") class PublicUsersAuditInsert(TypedDict): created_at: NotRequired[Annotated[Optional[datetime.datetime], Field(alias="created_at")]] id: NotRequired[Annotated[int, Field(alias="id")]] - previous_value: NotRequired[Annotated[Optional[Json[Any]], Field(alias="previous_value")]] + previous_value: NotRequired[Annotated[Optional[JsonValue], Field(alias="previous_value")]] user_id: NotRequired[Annotated[Optional[int], Field(alias="user_id")]] class PublicUsersAuditUpdate(TypedDict): created_at: NotRequired[Annotated[Optional[datetime.datetime], Field(alias="created_at")]] id: NotRequired[Annotated[int, Field(alias="id")]] - previous_value: NotRequired[Annotated[Optional[Json[Any]], Field(alias="previous_value")]] + previous_value: NotRequired[Annotated[Optional[JsonValue], Field(alias="previous_value")]] user_id: NotRequired[Annotated[Optional[int], Field(alias="user_id")]] class PublicAView(BaseModel): From 76f29032b6ffc1cf124d34bb46eca6f3623c5613 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 31 Aug 2026 14:52:57 +0200 Subject: [PATCH 4/4] fix(postgrest-typegen): mark Python composite type fields as Optional Postgres composite type attributes cannot carry NOT NULL constraints, so every field is inherently nullable, yet the generated Python models declared them as required non-None fields and rejected valid rows. Ported from supabase/postgres-meta#1063 (the Python side of the same nullability bug; the upstream PR only implemented the TypeScript part) --- packages/postgrest-typegen/src/generation/python.ts | 4 +++- packages/postgrest-typegen/test/generation/python.test.ts | 8 ++++++-- .../postgrest-typegen/test/parity/expected/python.txt | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/postgrest-typegen/src/generation/python.ts b/packages/postgrest-typegen/src/generation/python.ts index 249342d..ed728a6 100644 --- a/packages/postgrest-typegen/src/generation/python.ts +++ b/packages/postgrest-typegen/src/generation/python.ts @@ -142,7 +142,9 @@ class PythonContext { const attributeEntries: PythonBaseModelAttr[] = attributes.map( (attribute) => { const type = this.parsePgType(attribute.type!.name); - return new PythonBaseModelAttr(attribute.name, type, false); + // Composite type attributes cannot carry NOT NULL constraints in + // Postgres, so every field is inherently nullable. + return new PythonBaseModelAttr(attribute.name, type, true); }, ); diff --git a/packages/postgrest-typegen/test/generation/python.test.ts b/packages/postgrest-typegen/test/generation/python.test.ts index 046ec51..09739c2 100644 --- a/packages/postgrest-typegen/test/generation/python.test.ts +++ b/packages/postgrest-typegen/test/generation/python.test.ts @@ -159,6 +159,10 @@ describe("python typegen", () => { }), ); + // Composite type attributes cannot carry NOT NULL constraints in + // Postgres, so every field must be Optional. + expect(result).toContain('street: Optional[str] = Field(alias="street")'); + expect(result).toMatchInlineSnapshot(` "from __future__ import annotations @@ -185,8 +189,8 @@ describe("python typegen", () => { class PublicAddress(BaseModel): - street: str = Field(alias="street") - city: str = Field(alias="city")" + street: Optional[str] = Field(alias="street") + city: Optional[str] = Field(alias="city")" `); }); diff --git a/packages/postgrest-typegen/test/parity/expected/python.txt b/packages/postgrest-typegen/test/parity/expected/python.txt index 8a9fd05..516f301 100644 --- a/packages/postgrest-typegen/test/parity/expected/python.txt +++ b/packages/postgrest-typegen/test/parity/expected/python.txt @@ -256,7 +256,7 @@ class PublicTodosMatview(BaseModel): user_id: Optional[int] = Field(alias="user-id") class PublicCompositeTypeWithArrayAttribute(BaseModel): - my_text_array: List[str] = Field(alias="my_text_array") + my_text_array: Optional[List[str]] = Field(alias="my_text_array") class PublicCompositeTypeWithRecordAttribute(BaseModel): - todo: PublicTodos = Field(alias="todo") + todo: Optional[PublicTodos] = Field(alias="todo")