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.
- {tysql-0.1.0 → tysql-0.2.0}/PKG-INFO +22 -14
- {tysql-0.1.0 → tysql-0.2.0}/README.md +21 -13
- {tysql-0.1.0 → tysql-0.2.0}/pyproject.toml +6 -1
- {tysql-0.1.0 → tysql-0.2.0}/src/tysql/__init__.py +2 -1
- tysql-0.2.0/src/tysql/examples/1_dual_track.py +40 -0
- tysql-0.2.0/src/tysql/examples/2_select.py +24 -0
- tysql-0.2.0/src/tysql/examples/3_typo.py +23 -0
- tysql-0.2.0/src/tysql/examples/4_where.py +26 -0
- tysql-0.2.0/src/tysql/examples/5_join.py +38 -0
- tysql-0.2.0/src/tysql/examples/6_scope.py +30 -0
- tysql-0.2.0/src/tysql/examples/7_lazy_load.py +58 -0
- tysql-0.2.0/src/tysql/examples/8_pep827.py +28 -0
- tysql-0.2.0/src/tysql/examples/99_if_order_dependency.py +53 -0
- tysql-0.2.0/src/tysql/examples/__init__.py +8 -0
- tysql-0.2.0/src/tysql/examples/users.py +40 -0
- {tysql-0.1.0 → tysql-0.2.0}/src/tysql/render.py +14 -2
- {tysql-0.1.0 → tysql-0.2.0}/src/tysql/schema.py +5 -1
- {tysql-0.1.0 → tysql-0.2.0}/src/tysql/shapes.py +6 -4
- {tysql-0.1.0 → tysql-0.2.0}/src/tysql/__main__.py +0 -0
- {tysql-0.1.0 → tysql-0.2.0}/src/tysql/cli.py +0 -0
- {tysql-0.1.0 → tysql-0.2.0}/src/tysql/py.typed +0 -0
- {tysql-0.1.0 → tysql-0.2.0}/src/tysql/statements.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: tysql
|
|
3
|
-
Version: 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,
|
|
40
|
+
from tysql import Col, Cols, SerialPrimaryKey, Select, Table, run
|
|
50
41
|
|
|
51
42
|
|
|
52
43
|
class User(Table):
|
|
53
|
-
id:
|
|
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 (`
|
|
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,
|
|
22
|
+
from tysql import Col, Cols, SerialPrimaryKey, Select, Table, run
|
|
32
23
|
|
|
33
24
|
|
|
34
25
|
class User(Table):
|
|
35
|
-
id:
|
|
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 (`
|
|
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.
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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:
|
|
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,
|
|
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
|