diff --git a/packages/postgrest-typegen/src/generation/python.ts b/packages/postgrest-typegen/src/generation/python.ts index 8114e63..ed728a6 100644 --- a/packages/postgrest-typegen/src/generation/python.ts +++ b/packages/postgrest-typegen/src/generation/python.ts @@ -48,13 +48,12 @@ from typing import ( Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) +from typing_extensions import NotRequired, TypeAlias -from pydantic import BaseModel, Field, Json +from pydantic import BaseModel, Field, JsonValue ${concatLines(Object.values(ctx.user_enums))} @@ -143,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); }, ); @@ -229,7 +230,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 +276,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 +329,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 +370,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", @@ -395,9 +408,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 1d717a4..09739c2 100644 --- a/packages/postgrest-typegen/test/generation/python.test.ts +++ b/packages/postgrest-typegen/test/generation/python.test.ts @@ -63,13 +63,12 @@ describe("python typegen", () => { Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) + from typing_extensions import NotRequired, TypeAlias - from pydantic import BaseModel, Field, Json + from pydantic import BaseModel, Field, JsonValue PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] @@ -127,13 +126,12 @@ describe("python typegen", () => { Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) + from typing_extensions import NotRequired, TypeAlias - from pydantic import BaseModel, Field, Json + from pydantic import BaseModel, Field, JsonValue PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] @@ -161,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 @@ -171,13 +173,12 @@ describe("python typegen", () => { Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) + from typing_extensions import NotRequired, TypeAlias - from pydantic import BaseModel, Field, Json + from pydantic import BaseModel, Field, JsonValue PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] @@ -188,11 +189,78 @@ 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")" `); }); + 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("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({ + 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({ @@ -219,13 +287,12 @@ describe("python typegen", () => { Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) + 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 716adeb..516f301 100644 --- a/packages/postgrest-typegen/test/parity/expected/python.txt +++ b/packages/postgrest-typegen/test/parity/expected/python.txt @@ -7,13 +7,12 @@ from typing import ( Any, List, Literal, - NotRequired, Optional, - TypeAlias, TypedDict, ) +from typing_extensions import NotRequired, TypeAlias -from pydantic import BaseModel, Field, Json +from pydantic import BaseModel, Field, JsonValue PublicMemeStatus: TypeAlias = Literal["new", "old", "retired"] @@ -42,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")]] @@ -113,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") @@ -121,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")]] @@ -129,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")]] @@ -208,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): @@ -257,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")