tysql 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: tysql
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Experimental, type-level PostgreSQL query builder prototype using PEP 827 typemaps.
5
5
  Keywords: mypy,pep-827,postgresql,query-builder,typemap,typing
6
6
  Author: Ilias Dzhabbarov
@@ -18,15 +18,6 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  # tysql
20
20
 
21
- Install with [uv](https://docs.astral.sh/uv/) — from PyPI, or straight from GitHub for the
22
- latest commit:
23
-
24
- ```bash
25
- uv add tysql # core library, from PyPI
26
- uv add "tysql @ git+https://github.com/iliyasone/tysql.git" # or the latest from GitHub
27
- uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git" # + the mypy fork for `tysql check`
28
- ```
29
-
30
21
  **tysql** is an experimental, type-level query builder for PostgreSQL. A SQL
31
22
  statement is written as a *type* — `Select[User]` — and tysql
32
23
 
@@ -46,11 +37,11 @@ to make the *shape* of a query a static fact:
46
37
 
47
38
  ```python
48
39
  from typing import Literal
49
- from tysql import Col, Cols, PrimaryKey, Select, Table, run
40
+ from tysql import Col, Cols, SerialPrimaryKey, Select, Table, run
50
41
 
51
42
 
52
43
  class User(Table):
53
- id: PrimaryKey[int]
44
+ id: SerialPrimaryKey[int]
54
45
  age: int
55
46
  email: str
56
47
 
@@ -74,6 +65,22 @@ tysql ships a **CLI validator** over that fork so the errors show up today,
74
65
  exactly where a stock type checker would raise them once PEP 827 is standard —
75
66
  see [CLI](#cli).
76
67
 
68
+ ## Install
69
+
70
+ Install with [uv](https://docs.astral.sh/uv/) — from PyPI, or straight from GitHub for the
71
+ latest commit:
72
+
73
+ ```bash
74
+ uv add tysql # core library, from PyPI
75
+ uv add "tysql @ git+https://github.com/iliyasone/tysql.git" # or the latest from GitHub
76
+ uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git" # + the mypy fork for `tysql check`
77
+ ```
78
+
79
+ The [`mypy` fork](https://github.com/iliyasone/mypy-typemap) is only needed for
80
+ `tysql check` (see [CLI](#cli)); it cannot ship as a PyPI extra (direct URL), so
81
+ it is installed from GitHub. Runnable demo snippets ship inside the package under
82
+ [`tysql/examples/`](src/tysql/examples).
83
+
77
84
  ## What works
78
85
 
79
86
  Type inference below holds on **both** tracks (the `mypy` fork *and* runtime
@@ -83,7 +90,7 @@ Type inference below holds on **both** tracks (the `mypy` fork *and* runtime
83
90
  | --- | :---: | :---: |
84
91
  | `CREATE TABLE` (incl. `SERIAL PRIMARY KEY`, `REFERENCES`) | — | ✅ |
85
92
  | `INSERT` — params inferred, `RETURNING` the primary key | ✅ | ✅ |
86
- | `SELECT *` — full row, primary key unwrapped (`PrimaryKey[int]` → `int`) | ✅ | ✅ |
93
+ | `SELECT *` — full row, primary key unwrapped (`SerialPrimaryKey[int]` → `int`) | ✅ | ✅ |
87
94
  | `SELECT` projection — `Cols[...]` picks columns | ✅ | ✅ |
88
95
  | Column alias — `As[col, Literal["name"]]` | ✅ | ✅ |
89
96
  | `WHERE` — `Param`s collected into the inferred parameter mapping | ✅ | ✅ |
@@ -163,7 +170,8 @@ FROM "user" INNER JOIN "post" ON "user"."id" = "post"."author"
163
170
  GROUP BY "user"."id" ORDER BY "user"."id" ASC;
164
171
  ```
165
172
 
166
- A larger example schema lives in [`src/examples/users.py`](src/examples/users.py).
173
+ A larger example schema lives in [`src/tysql/examples/users.py`](src/tysql/examples/users.py),
174
+ alongside the numbered demo snippets the [playground](https://github.com/iliyasone/tysql-playground) serves.
167
175
 
168
176
  ## CLI
169
177
 
@@ -1,14 +1,5 @@
1
1
  # tysql
2
2
 
3
- Install with [uv](https://docs.astral.sh/uv/) — from PyPI, or straight from GitHub for the
4
- latest commit:
5
-
6
- ```bash
7
- uv add tysql # core library, from PyPI
8
- uv add "tysql @ git+https://github.com/iliyasone/tysql.git" # or the latest from GitHub
9
- uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git" # + the mypy fork for `tysql check`
10
- ```
11
-
12
3
  **tysql** is an experimental, type-level query builder for PostgreSQL. A SQL
13
4
  statement is written as a *type* — `Select[User]` — and tysql
14
5
 
@@ -28,11 +19,11 @@ to make the *shape* of a query a static fact:
28
19
 
29
20
  ```python
30
21
  from typing import Literal
31
- from tysql import Col, Cols, PrimaryKey, Select, Table, run
22
+ from tysql import Col, Cols, SerialPrimaryKey, Select, Table, run
32
23
 
33
24
 
34
25
  class User(Table):
35
- id: PrimaryKey[int]
26
+ id: SerialPrimaryKey[int]
36
27
  age: int
37
28
  email: str
38
29
 
@@ -56,6 +47,22 @@ tysql ships a **CLI validator** over that fork so the errors show up today,
56
47
  exactly where a stock type checker would raise them once PEP 827 is standard —
57
48
  see [CLI](#cli).
58
49
 
50
+ ## Install
51
+
52
+ Install with [uv](https://docs.astral.sh/uv/) — from PyPI, or straight from GitHub for the
53
+ latest commit:
54
+
55
+ ```bash
56
+ uv add tysql # core library, from PyPI
57
+ uv add "tysql @ git+https://github.com/iliyasone/tysql.git" # or the latest from GitHub
58
+ uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git" # + the mypy fork for `tysql check`
59
+ ```
60
+
61
+ The [`mypy` fork](https://github.com/iliyasone/mypy-typemap) is only needed for
62
+ `tysql check` (see [CLI](#cli)); it cannot ship as a PyPI extra (direct URL), so
63
+ it is installed from GitHub. Runnable demo snippets ship inside the package under
64
+ [`tysql/examples/`](src/tysql/examples).
65
+
59
66
  ## What works
60
67
 
61
68
  Type inference below holds on **both** tracks (the `mypy` fork *and* runtime
@@ -65,7 +72,7 @@ Type inference below holds on **both** tracks (the `mypy` fork *and* runtime
65
72
  | --- | :---: | :---: |
66
73
  | `CREATE TABLE` (incl. `SERIAL PRIMARY KEY`, `REFERENCES`) | — | ✅ |
67
74
  | `INSERT` — params inferred, `RETURNING` the primary key | ✅ | ✅ |
68
- | `SELECT *` — full row, primary key unwrapped (`PrimaryKey[int]` → `int`) | ✅ | ✅ |
75
+ | `SELECT *` — full row, primary key unwrapped (`SerialPrimaryKey[int]` → `int`) | ✅ | ✅ |
69
76
  | `SELECT` projection — `Cols[...]` picks columns | ✅ | ✅ |
70
77
  | Column alias — `As[col, Literal["name"]]` | ✅ | ✅ |
71
78
  | `WHERE` — `Param`s collected into the inferred parameter mapping | ✅ | ✅ |
@@ -145,7 +152,8 @@ FROM "user" INNER JOIN "post" ON "user"."id" = "post"."author"
145
152
  GROUP BY "user"."id" ORDER BY "user"."id" ASC;
146
153
  ```
147
154
 
148
- A larger example schema lives in [`src/examples/users.py`](src/examples/users.py).
155
+ A larger example schema lives in [`src/tysql/examples/users.py`](src/tysql/examples/users.py),
156
+ alongside the numbered demo snippets the [playground](https://github.com/iliyasone/tysql-playground) serves.
149
157
 
150
158
  ## CLI
151
159
 
@@ -19,7 +19,7 @@ license = {text = "MIT"}
19
19
  name = "tysql"
20
20
  readme = "README.md"
21
21
  requires-python = ">=3.14"
22
- version = "0.1.0"
22
+ version = "0.2.0"
23
23
 
24
24
  [project.scripts]
25
25
  tysql = "tysql.cli:main"
@@ -46,6 +46,10 @@ test = [
46
46
 
47
47
  [tool.mypy]
48
48
  enable_error_code = ["ignore-without-code"]
49
+ # The bundled examples are standalone demo snippets — some carry deliberate type
50
+ # errors (a typo'd column, an out-of-scope reference) that are the point of the
51
+ # example. They ship in the wheel but are not part of this repo's type contract.
52
+ exclude = ["src/tysql/examples/", "scratch/"]
49
53
  python_version = "3.14"
50
54
  show_error_codes = true
51
55
  warn_unused_ignores = true
@@ -58,6 +62,7 @@ python_functions = ["test_*"]
58
62
  testpaths = ["tests"]
59
63
 
60
64
  [tool.ruff]
65
+ extend-exclude = ["src/tysql/examples"]
61
66
  line-length = 100
62
67
  target-version = "py314"
63
68
 
@@ -5,7 +5,7 @@ parameters and result rows they carry, and ``render`` turns them into SQL text.
5
5
  """
6
6
 
7
7
  from tysql.render import render
8
- from tysql.schema import Col, Column, ForeignKey, PrimaryKey, Table
8
+ from tysql.schema import Col, Column, ForeignKey, PrimaryKey, SerialPrimaryKey, Table
9
9
  from tysql.shapes import ParamsOf, ResultOf, run
10
10
  from tysql.statements import (
11
11
  As,
@@ -43,6 +43,7 @@ __all__ = [
43
43
  "PrimaryKey",
44
44
  "ResultOf",
45
45
  "Select",
46
+ "SerialPrimaryKey",
46
47
  "Statement",
47
48
  "Table",
48
49
  "Where",
@@ -0,0 +1,40 @@
1
+ # Paired tests — pytest × mypy, the dual-track setup this playground runs on.
2
+ # One feature, two adjacent tests. mypy_test_* is static-only (assert_type
3
+ # positives, type-ignore negatives kept honest by --warn-unused-ignores);
4
+ # test_* is runtime (pytest + eval_typing). ⌘/Ctrl+Enter runs both suites at
5
+ # once — Check type-checks with the PEP 827 mypy fork, Test runs pytest here in
6
+ # your browser. That both agree is the whole point of the prototype.
7
+
8
+ from typing import TYPE_CHECKING, Literal, assert_type
9
+
10
+ import typemap_extensions as tm
11
+ from typemap.type_eval import eval_typing
12
+
13
+
14
+ class Point:
15
+ x: int
16
+ y: float
17
+
18
+
19
+ # A TypedDict computed from Point's annotations, names uppercased at the type level.
20
+ type Loud = tm.NewTypedDict[
21
+ *[tm.Member[tm.Uppercase[a.name], a.type] for a in tm.Iter[tm.Attrs[Point]]]
22
+ ]
23
+
24
+
25
+ def mypy_test_loud_keys() -> None: # static: mypy checks it, pytest skips it
26
+ if TYPE_CHECKING:
27
+ p: Loud = {"X": 1, "Y": 2.0}
28
+ assert_type(p["X"], int)
29
+ assert_type(p["Y"], float)
30
+ p["x"] # type: ignore[misc] # negative: the lowercase key must be rejected
31
+
32
+
33
+ def test_loud_keys() -> None: # runtime: pytest checks the evaluated class
34
+ D = eval_typing(Loud)
35
+ assert D.__annotations__ == {"X": int, "Y": float}
36
+ assert D.__required_keys__ == frozenset({"X", "Y"})
37
+
38
+
39
+ def test_uppercase() -> None:
40
+ assert eval_typing(tm.Uppercase[Literal["pep 827"]]) == Literal["PEP 827"]
@@ -0,0 +1,24 @@
1
+ # SELECT & projection — a statement is a type.
2
+ # Projected columns become the row type; reading any other column is a type error.
3
+
4
+ from typing import TYPE_CHECKING, Literal
5
+
6
+ from tysql import Col, Cols, PrimaryKey, Select, Table, run
7
+ from tysql.render import render
8
+
9
+
10
+ class User(Table):
11
+ id: PrimaryKey[int]
12
+ age: int
13
+ email: str
14
+
15
+
16
+ stmt = Select[User, Cols[Col[User, Literal["id"]], Col[User, Literal["email"]]]]
17
+
18
+ print(render(stmt)) # Run renders the SQL — right in your browser
19
+
20
+ if TYPE_CHECKING: # run() is the static contract; there is no database bridge yet
21
+ rows = run(stmt, data=None)
22
+ reveal_type(rows[0]["id"]) # int — inferred
23
+ reveal_type(rows[0]["email"]) # str — inferred
24
+ rows[0]["age"] # error: "age" was not selected
@@ -0,0 +1,23 @@
1
+ # Catch a typo — a column that does not exist is rejected at the reference site.
2
+ # Run still renders the broken SQL: only the type checker stands between this
3
+ # statement and your database.
4
+
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ from tysql import Col, Cols, PrimaryKey, Select, Table, run
8
+ from tysql.render import render
9
+
10
+
11
+ class User(Table):
12
+ id: PrimaryKey[int]
13
+ age: int
14
+ email: str
15
+
16
+
17
+ # "emial" is not a column of User — the statement itself is ill-typed
18
+ stmt = Select[User, Cols[Col[User, Literal["emial"]]]]
19
+
20
+ print(render(stmt)) # runtime happily renders SELECT "user"."emial" …
21
+
22
+ if TYPE_CHECKING:
23
+ rows = run(stmt, data=None) # error: Col: no such column
@@ -0,0 +1,26 @@
1
+ # WHERE & inferred params — params in the WHERE clause become required, typed
2
+ # keys of data=. A wrong value type is rejected.
3
+
4
+ from typing import TYPE_CHECKING, Literal
5
+
6
+ from tysql import Col, Cols, Eq, Param, PrimaryKey, Select, Table, Where, run
7
+ from tysql.render import render
8
+
9
+
10
+ class User(Table):
11
+ id: PrimaryKey[int]
12
+ age: int
13
+ email: str
14
+
15
+
16
+ stmt = Select[
17
+ User,
18
+ Cols[Col[User, Literal["id"]]],
19
+ Where[Eq[Col[User, Literal["age"]], Param[Literal["min_age"], int]]],
20
+ ]
21
+
22
+ print(render(stmt)) # SELECT … WHERE "user"."age" = %(min_age)s
23
+
24
+ if TYPE_CHECKING: # the params of run() are inferred from the statement
25
+ run(stmt, data={"min_age": 21}) # ok
26
+ run(stmt, data={"min_age": "21"}) # error: "min_age" must be int
@@ -0,0 +1,38 @@
1
+ # JOIN, COUNT & GROUP BY — an INNER JOIN with an ON predicate, a Count aggregate
2
+ # under an alias, GROUP BY and ORDER BY. The row type is computed through all of it.
3
+
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ from tysql import (
8
+ As, Col, Cols, Count, Eq, ForeignKey, GroupBy, InnerJoin, On, OrderBy,
9
+ PrimaryKey, Select, Table, run,
10
+ )
11
+ from tysql.render import render
12
+
13
+
14
+ class User(Table):
15
+ id: PrimaryKey[int]
16
+ age: int
17
+ email: str
18
+
19
+
20
+ class Post(Table):
21
+ id: PrimaryKey[int]
22
+ author: ForeignKey[User, Literal["id"]]
23
+ created_at: datetime
24
+ text: str
25
+
26
+
27
+ stmt = Select[
28
+ InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]]],
29
+ Cols[Col[User, Literal["id"]], As[Count[Col[Post, Literal["id"]]], Literal["n_posts"]]],
30
+ GroupBy[Col[User, Literal["id"]]],
31
+ OrderBy[Col[User, Literal["id"]], Literal["asc"]],
32
+ ]
33
+
34
+ print(render(stmt))
35
+
36
+ if TYPE_CHECKING:
37
+ rows = run(stmt, data=None)
38
+ reveal_type(rows[0]["n_posts"]) # int
@@ -0,0 +1,30 @@
1
+ # Column out of scope — projecting a column whose table is not in the FROM/JOIN
2
+ # clause is caught; the column is mapped back to its table.
3
+
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ from tysql import Col, Cols, ForeignKey, PrimaryKey, Select, Table, run
8
+ from tysql.render import render
9
+
10
+
11
+ class User(Table):
12
+ id: PrimaryKey[int]
13
+ age: int
14
+ email: str
15
+
16
+
17
+ class Post(Table):
18
+ id: PrimaryKey[int]
19
+ author: ForeignKey[User, Literal["id"]]
20
+ created_at: datetime
21
+ text: str
22
+
23
+
24
+ # Post.text is a real column — but Post is not in the FROM clause
25
+ stmt = Select[User, Cols[Col[Post, Literal["text"]]]]
26
+
27
+ print(render(stmt)) # runtime renders SQL that selects from the wrong table
28
+
29
+ if TYPE_CHECKING:
30
+ rows = run(stmt, data=None) # error: Col: table is not in the FROM clause
@@ -0,0 +1,58 @@
1
+ # The lazy-load lie — what tysql fixes about ORMs.
2
+ #
3
+ # SQLAlchemy (for contrast, not runnable here):
4
+ # post = session.scalars(select(Post).where(Post.id == 1)).one()
5
+ # reveal_type(post.author.posts[0].text) # str — a whole object graph
6
+ # ...yet select(Post) loaded ONE row; every "." past `post` is a hidden query or
7
+ # an error on a closed session. The type promises data the query never loaded.
8
+ #
9
+ # In tysql the result TYPE is the load plan: a statement returns a flat row of
10
+ # exactly the columns it selected. What you did not load, you cannot name.
11
+
12
+ from typing import TYPE_CHECKING, Literal
13
+
14
+ from tysql import (
15
+ Col, Cols, Eq, ForeignKey, InnerJoin, On, Param, PrimaryKey, Select, Table, Where, run,
16
+ )
17
+ from tysql.render import render
18
+
19
+
20
+ class User(Table):
21
+ id: PrimaryKey[int]
22
+ age: int
23
+ email: str
24
+
25
+
26
+ class Post(Table):
27
+ id: PrimaryKey[int]
28
+ author: ForeignKey[User, Literal["id"]]
29
+ text: str
30
+
31
+
32
+ # You selected Post's own columns — so that is exactly the row you get back.
33
+ post_only = Select[
34
+ Post,
35
+ Cols[Col[Post, Literal["id"]], Col[Post, Literal["author"]], Col[Post, Literal["text"]]],
36
+ Where[Eq[Col[Post, Literal["id"]], Param[Literal["id"], int]]],
37
+ ]
38
+
39
+ print(render(post_only))
40
+
41
+ if TYPE_CHECKING:
42
+ row = run(post_only, data={"id": 1})[0]
43
+ reveal_type(row["text"]) # str
44
+ reveal_type(row["author"]) # the raw foreign key — NOT a hydrated User object
45
+ row["email"] # error: the user was never loaded — "email" is not on this row
46
+
47
+ # Want the user's columns? Load them, explicitly, with a join. Only now do they
48
+ # appear on the row type — because the join is what put them there.
49
+ with_author = Select[
50
+ InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]]],
51
+ Cols[Col[Post, Literal["text"]], Col[User, Literal["email"]]],
52
+ ]
53
+
54
+ print(render(with_author))
55
+
56
+ if TYPE_CHECKING:
57
+ joined = run(with_author, data=None)[0]
58
+ reveal_type(joined["email"]) # str — present now, and only now
@@ -0,0 +1,28 @@
1
+ # Pure PEP 827 — no tysql: compute types from types.
2
+ # Check evaluates this statically with the mypy fork; Run evaluates the very same
3
+ # program with typemap at runtime, in your browser.
4
+
5
+ from typing import Literal
6
+
7
+ import typemap_extensions as tm
8
+ from typemap.type_eval import eval_typing
9
+
10
+
11
+ class Point:
12
+ x: int
13
+ y: float
14
+
15
+
16
+ # A TypedDict computed from Point's annotations — names uppercased at the type level
17
+ Loud = tm.NewTypedDict[
18
+ *[tm.Member[tm.Uppercase[a.name], a.type] for a in tm.Iter[tm.Attrs[Point]]]
19
+ ]
20
+
21
+
22
+ def f(p: Loud) -> None:
23
+ reveal_type(p["X"]) # int — computed statically
24
+ reveal_type(p["Y"]) # float
25
+ p["x"] # error: the key is "X" now
26
+
27
+
28
+ print(eval_typing(tm.Uppercase[Literal["pep 827"]])) # the same machinery, at runtime
@@ -0,0 +1,53 @@
1
+ # Order-dependent caching — a cursed corner where evaluation order leaks.
2
+ # Two identical functions resolve their type-level `if` to DIFFERENT annotations
3
+ # depending on which evaluator touches them first: the typemap-aware and
4
+ # typemap-naive lookups share one cache, so whoever runs first wins. mypy
5
+ # evaluates the `if` correctly; the runtime tracks disagree by construction.
6
+
7
+ from typing import TYPE_CHECKING, Literal, assert_type
8
+
9
+ import typemap_extensions as typing
10
+ from typemap.type_eval import eval_call_with_types, eval_typing
11
+
12
+
13
+ def foo() -> int if typing.Bool[Literal[True]] else Literal["UNREACHABLE"]:
14
+ raise NotImplementedError
15
+
16
+
17
+ def bar() -> int if typing.Bool[Literal[True]] else Literal["UNREACHABLE"]:
18
+ raise NotImplementedError
19
+
20
+
21
+ # identical functions
22
+
23
+
24
+ def mypy_flow() -> None:
25
+ if TYPE_CHECKING:
26
+ assert_type(foo(), int)
27
+ assert_type(bar(), int)
28
+ # ✅ mypy evaluates `if` correctly
29
+
30
+
31
+ def test_foo() -> None:
32
+ assert foo.__annotations__["return"] == Literal["UNREACHABLE"]
33
+ # ✅ typemap-unaware annotations lookup: `typing.Bool[...]` resolves to False
34
+ # (expected for a naive evaluator)
35
+
36
+ assert eval_typing(foo).__annotations__["return"] == Literal["UNREACHABLE"]
37
+ # ⚠️ typemap-aware evaluator resolved to the incorrect cache instead of reducing
38
+
39
+ assert eval_call_with_types(foo) is int
40
+ # ✅ eval_call_with_types does not use the cache (bug?)
41
+
42
+
43
+ def test_bar() -> None:
44
+ assert eval_typing(bar).__annotations__["return"] is int
45
+ # ✅ typemap-aware evaluator ran first — correct type put in the cache
46
+
47
+ assert bar.__annotations__["return"] is int
48
+ # ⚠️ typemap-unaware lookup used the cached typemap-aware value
49
+
50
+
51
+ if __name__ == "__main__":
52
+ print(f"{foo.__annotations__=}")
53
+ print(f"{bar.__annotations__=}")
@@ -0,0 +1,8 @@
1
+ """Bundled tysql examples.
2
+
3
+ Each ``*.py`` sibling is a self-contained snippet demonstrating one feature. The
4
+ files ship in the wheel so tools (e.g. the playground) can read them straight
5
+ from the installed package with ``importlib.resources.files("tysql.examples")``.
6
+ The digit prefix orders them; the first ``# Title — blurb`` comment line is the
7
+ display title.
8
+ """
@@ -0,0 +1,40 @@
1
+ """A small social-graph schema used across the tests and the README examples."""
2
+
3
+ from datetime import datetime
4
+ from typing import Literal
5
+
6
+ from tysql.schema import ForeignKey, SerialPrimaryKey, Table
7
+
8
+
9
+ class User(Table):
10
+ id: SerialPrimaryKey[int]
11
+ age: int
12
+ email: str
13
+
14
+
15
+ class Post(Table):
16
+ id: SerialPrimaryKey[int]
17
+ author: ForeignKey[User, Literal["id"]]
18
+ created_at: datetime
19
+ text: str
20
+
21
+
22
+ class Comment(Table):
23
+ id: SerialPrimaryKey[int]
24
+ author: ForeignKey[User, Literal["id"]]
25
+ post: ForeignKey[Post, Literal["id"]]
26
+ created_at: datetime
27
+ text: str
28
+
29
+
30
+ class Follow(Table):
31
+ follower: ForeignKey[User, Literal["id"]]
32
+ followed: ForeignKey[User, Literal["id"]]
33
+ created_at: datetime
34
+
35
+
36
+ class Reaction(Table):
37
+ user: ForeignKey[User, Literal["id"]]
38
+ post: ForeignKey[Post, Literal["id"]]
39
+ kind: str
40
+ created_at: datetime
@@ -11,7 +11,7 @@ connection here, only text.
11
11
  from datetime import datetime
12
12
  from typing import Any, get_args, get_origin
13
13
 
14
- from tysql.schema import Column, ForeignKey, PrimaryKey
14
+ from tysql.schema import Column, ForeignKey, PrimaryKey, SerialPrimaryKey
15
15
  from tysql.statements import (
16
16
  As,
17
17
  Cols,
@@ -127,14 +127,24 @@ def _columns(table: type) -> list[Column[Any, Any, Any]]:
127
127
  return list(table.__tysql_columns__.values()) # type: ignore[attr-defined]
128
128
 
129
129
 
130
+ _PRIMARY_KEY_NOT_IMPLEMENTED = (
131
+ "render: PrimaryKey (general, non-serial primary key) is not implemented "
132
+ "yet -- use SerialPrimaryKey instead."
133
+ )
134
+
135
+
130
136
  def _is_primary_key(annotation: Any) -> bool:
131
- return get_origin(annotation) is PrimaryKey
137
+ if get_origin(annotation) is PrimaryKey:
138
+ raise NotImplementedError(_PRIMARY_KEY_NOT_IMPLEMENTED)
139
+ return get_origin(annotation) is SerialPrimaryKey
132
140
 
133
141
 
134
142
  def _column_ddl(column: Column[Any, Any, Any]) -> str:
135
143
  annotation = column.type
136
144
  origin = get_origin(annotation)
137
145
  if origin is PrimaryKey:
146
+ raise NotImplementedError(_PRIMARY_KEY_NOT_IMPLEMENTED)
147
+ if origin is SerialPrimaryKey:
138
148
  inner = get_args(annotation)[0]
139
149
  kind = "SERIAL PRIMARY KEY" if inner is int else f"{_base_pg_type(inner)} PRIMARY KEY"
140
150
  return f"{_ident(column.name)} {kind}"
@@ -143,6 +153,8 @@ def _column_ddl(column: Column[Any, Any, Any]) -> str:
143
153
  name = _literal(name_literal)
144
154
  referenced = ref.__tysql_columns__[name].type
145
155
  if get_origin(referenced) is PrimaryKey:
156
+ raise NotImplementedError(_PRIMARY_KEY_NOT_IMPLEMENTED)
157
+ if get_origin(referenced) is SerialPrimaryKey:
146
158
  referenced = get_args(referenced)[0]
147
159
  return (
148
160
  f"{_ident(column.name)} {_base_pg_type(referenced)} NOT NULL "
@@ -51,10 +51,14 @@ type Col[C, N] = (
51
51
  )
52
52
 
53
53
 
54
- class PrimaryKey[T]:
54
+ class SerialPrimaryKey[T]:
55
55
  """Marks a column as the table's primary key; unwrapped to ``T`` in results."""
56
56
 
57
57
 
58
+ class PrimaryKey[T]:
59
+ """General (non-serial) PRIMARY KEY. Not implemented yet — use SerialPrimaryKey."""
60
+
61
+
58
62
  class ForeignKey[Owner, Name]:
59
63
  """A foreign key referencing ``Owner``'s column ``Name``.
60
64
 
@@ -14,7 +14,7 @@ these are written:
14
14
  import sys
15
15
  from typing import Any, Literal
16
16
 
17
- from tysql.schema import Column, PrimaryKey
17
+ from tysql.schema import Column, SerialPrimaryKey
18
18
  from tysql.statements import (
19
19
  As,
20
20
  Cols,
@@ -42,15 +42,17 @@ type Row[T] = typing.NewTypedDict[
42
42
  *[
43
43
  typing.Member[x.name, typing.GetArg[x.type, Column, Literal[2]]]
44
44
  for x in typing.Iter[typing.Attrs[T]]
45
- if not typing.IsAssignable[x.type, Column[Any, Any, PrimaryKey[Any]]]
45
+ if not typing.IsAssignable[x.type, Column[Any, Any, SerialPrimaryKey[Any]]]
46
46
  ]
47
47
  ]
48
48
 
49
49
 
50
- # A column's value as it comes back from a query: PrimaryKey[int] -> int,
50
+ # A column's value as it comes back from a query: SerialPrimaryKey[int] -> int,
51
51
  # anything else unchanged.
52
52
  type Unwrap[T] = (
53
- typing.GetArg[T, PrimaryKey, Literal[0]] if typing.IsAssignable[T, PrimaryKey[Any]] else T
53
+ typing.GetArg[T, SerialPrimaryKey, Literal[0]]
54
+ if typing.IsAssignable[T, SerialPrimaryKey[Any]]
55
+ else T
54
56
  )
55
57
 
56
58
 
File without changes
File without changes
File without changes
File without changes