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
35 changes: 25 additions & 10 deletions packages/postgrest-typegen/src/generation/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))}

Expand Down Expand Up @@ -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);
},
);

Expand Down Expand Up @@ -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}]`;
}
}
Expand Down Expand Up @@ -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)})`;
}
}

Expand Down Expand Up @@ -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}`;
}
Expand Down Expand Up @@ -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<string, string> = {
// Bool
bool: "bool",
Expand Down Expand Up @@ -395,9 +408,11 @@ const PY_TYPE_MAP: Record<string, string> = {
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",
Expand Down
95 changes: 81 additions & 14 deletions packages/postgrest-typegen/test/generation/python.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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"]

Expand Down Expand Up @@ -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

Expand All @@ -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"]

Expand All @@ -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({
Expand All @@ -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"]

Expand Down
39 changes: 19 additions & 20 deletions packages/postgrest-typegen/test/parity/expected/python.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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")]]

Expand All @@ -113,23 +112,23 @@ 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")

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")]]

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")]]

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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")