tysql 0.1.0__tar.gz → 0.3.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.3.0/PKG-INFO +243 -0
- tysql-0.3.0/README.md +223 -0
- {tysql-0.1.0 → tysql-0.3.0}/pyproject.toml +14 -4
- {tysql-0.1.0 → tysql-0.3.0}/src/tysql/__init__.py +11 -2
- tysql-0.3.0/src/tysql/connection.py +35 -0
- tysql-0.3.0/src/tysql/examples/1_dual_track.py +40 -0
- tysql-0.3.0/src/tysql/examples/2_select.py +24 -0
- tysql-0.3.0/src/tysql/examples/3_typo.py +23 -0
- tysql-0.3.0/src/tysql/examples/4_where.py +26 -0
- tysql-0.3.0/src/tysql/examples/5_join.py +45 -0
- tysql-0.3.0/src/tysql/examples/6_scope.py +30 -0
- tysql-0.3.0/src/tysql/examples/7_lazy_load.py +59 -0
- tysql-0.3.0/src/tysql/examples/8_pep827.py +28 -0
- tysql-0.3.0/src/tysql/examples/99_if_order_dependency.py +53 -0
- tysql-0.3.0/src/tysql/examples/__init__.py +8 -0
- tysql-0.3.0/src/tysql/examples/users.py +40 -0
- tysql-0.3.0/src/tysql/execute.py +97 -0
- {tysql-0.1.0 → tysql-0.3.0}/src/tysql/render.py +14 -2
- {tysql-0.1.0 → tysql-0.3.0}/src/tysql/schema.py +5 -1
- {tysql-0.1.0 → tysql-0.3.0}/src/tysql/shapes.py +9 -16
- tysql-0.1.0/PKG-INFO +0 -203
- tysql-0.1.0/README.md +0 -185
- tysql-0.1.0/src/tysql/__main__.py +0 -4
- tysql-0.1.0/src/tysql/cli.py +0 -101
- {tysql-0.1.0 → tysql-0.3.0}/src/tysql/py.typed +0 -0
- {tysql-0.1.0 → tysql-0.3.0}/src/tysql/statements.py +0 -0
tysql-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: tysql
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Experimental, type-level PostgreSQL query builder prototype using PEP 827 typemaps.
|
|
5
|
+
Keywords: mypy,pep-827,postgresql,query-builder,typemap,typing
|
|
6
|
+
Author: Ilias Dzhabbarov
|
|
7
|
+
Author-email: Ilias Dzhabbarov <iliyas.dzabbarov@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Dist: python-typemap>=0.1.0
|
|
14
|
+
Requires-Dist: psycopg>=3.0.0 ; extra == 'psycopg'
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Project-URL: Homepage, https://github.com/iliyasone/tysql
|
|
17
|
+
Project-URL: Issues, https://github.com/iliyasone/tysql/issues
|
|
18
|
+
Provides-Extra: psycopg
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# tysql
|
|
22
|
+
|
|
23
|
+
**tysql** is an experimental, type-level query builder for PostgreSQL. A SQL
|
|
24
|
+
statement is written as a _type_ — `Select[User]` — and tysql
|
|
25
|
+
|
|
26
|
+
- **statically infers the result type of every statement** and **rejects
|
|
27
|
+
ill-typed statements with a type error**, using the type operators from
|
|
28
|
+
[PEP 827](https://peps.python.org/pep-0827/);
|
|
29
|
+
- **renders the statement to PostgreSQL text** you could hand to a driver; and
|
|
30
|
+
- **executes it** on a psycopg connection you own — with the `tysql[psycopg]`
|
|
31
|
+
extra — returning rows in the inferred shape.
|
|
32
|
+
|
|
33
|
+
It is a research prototype: the API may change and SQL coverage is narrow (see [what works](#what-works))
|
|
34
|
+
|
|
35
|
+
## The idea
|
|
36
|
+
|
|
37
|
+
PEP 827 lets a type checker evaluate small type-level programs. tysql uses that
|
|
38
|
+
to make the _shape_ of a query a static fact:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import psycopg
|
|
42
|
+
from typing import Literal
|
|
43
|
+
from tysql import Col, Cols, SerialPrimaryKey, Select, Table, run
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class User(Table):
|
|
47
|
+
id: SerialPrimaryKey[int]
|
|
48
|
+
age: int
|
|
49
|
+
email: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
conn = psycopg.connect("postgresql://localhost/mydb")
|
|
53
|
+
|
|
54
|
+
# SELECT id, email FROM "user" — run on the connection you own
|
|
55
|
+
rows = run(
|
|
56
|
+
Select[User, Cols[Col[User, Literal["id"]], Col[User, Literal["email"]]]],
|
|
57
|
+
data=None,
|
|
58
|
+
conn=conn,
|
|
59
|
+
)
|
|
60
|
+
rows[0]["id"] # int — inferred
|
|
61
|
+
rows[0]["email"] # str — inferred
|
|
62
|
+
rows[0]["age"] # type error: "age" is not in the projected row
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`run`'s signature _is_ the product: `data` is typed to the statement's
|
|
66
|
+
parameters and the return type to its rows — both computed from the statement
|
|
67
|
+
type by the combinators in [`tysql/shapes.py`](src/tysql/shapes.py). Pass a
|
|
68
|
+
psycopg `conn` (the `tysql[psycopg]` extra) and it renders and executes the
|
|
69
|
+
statement, returning those rows.
|
|
70
|
+
|
|
71
|
+
Until PEP 827 lands in a released type checker, the same evaluation is done two
|
|
72
|
+
ways: at runtime by [`typemap`](https://github.com/iliyasone/python-typemap),
|
|
73
|
+
and statically by a [`mypy` fork](https://github.com/iliyasone/mypy-typemap).
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
Install the core library from PyPI with [uv](https://docs.astral.sh/uv/) or pip:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uv add tysql
|
|
81
|
+
# or pip install tysql
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
That includes the runtime side: build statement types and render them to SQL. To
|
|
85
|
+
also **execute** statements against PostgreSQL, add the psycopg driver extra:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
uv add "tysql[psycopg]"
|
|
89
|
+
# or pip install tysql[psycopg]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Enable type-checking
|
|
93
|
+
|
|
94
|
+
The static rejection of ill-typed statements runs on a [mypy fork](https://github.com/iliyasone/mypy-typemap) that evaluates the PEP 827 combinators
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv run mypy .
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Runnable demo snippets ship inside the package under
|
|
105
|
+
[`tysql/examples/`](src/tysql/examples).
|
|
106
|
+
|
|
107
|
+
## What works
|
|
108
|
+
|
|
109
|
+
Type inference below holds on **both** tracks (the `mypy` fork _and_ runtime
|
|
110
|
+
`eval_typing`); SQL rendering is runtime (`tysql.render.render`).
|
|
111
|
+
|
|
112
|
+
| Capability | Type inference | SQL rendering |
|
|
113
|
+
| ------------------------------------------------------------------------------ | :------------: | :-----------: |
|
|
114
|
+
| `CREATE TABLE` (incl. `SERIAL PRIMARY KEY`, `REFERENCES`) | — | ✅ |
|
|
115
|
+
| `INSERT` — params inferred, `RETURNING` the primary key | ✅ | ✅ |
|
|
116
|
+
| `SELECT *` — full row, primary key unwrapped (`SerialPrimaryKey[int]` → `int`) | ✅ | ✅ |
|
|
117
|
+
| `SELECT` projection — `Cols[...]` picks columns | ✅ | ✅ |
|
|
118
|
+
| Column alias — `As[col, Literal["name"]]` | ✅ | ✅ |
|
|
119
|
+
| `WHERE` — `Param`s collected into the inferred parameter mapping | ✅ | ✅ |
|
|
120
|
+
| `INNER JOIN` with an explicit `On` predicate | ✅ | ✅ |
|
|
121
|
+
| Aggregate `Count` (result typed `int`) | ✅ | ✅ |
|
|
122
|
+
| `GROUP BY` / `ORDER BY` | ✅ | ✅ |
|
|
123
|
+
|
|
124
|
+
### What it rejects
|
|
125
|
+
|
|
126
|
+
The parameter and row types are exact `TypedDict`s, so a type checker rejects, at
|
|
127
|
+
check time: a column that doesn't exist on its table
|
|
128
|
+
(`Col: no such column`); a projected column whose table isn't in the `FROM`/`JOIN`
|
|
129
|
+
(`Col: table is not in the FROM clause` — the "map the column to the table"
|
|
130
|
+
check); reading a result column that wasn't selected; and an `INSERT`/`WHERE`
|
|
131
|
+
`data` payload with a missing, extra, mis-named or wrong-typed key (the primary
|
|
132
|
+
key may not be supplied on insert).
|
|
133
|
+
|
|
134
|
+
Column checks are **per reference site**. A ✅ is enforced on both tracks; a ❌ is
|
|
135
|
+
currently **accepted** — a known false negative, not a guarantee:
|
|
136
|
+
|
|
137
|
+
| Column reference site | exists on its table | belongs to the `FROM` | operand types compatible |
|
|
138
|
+
| ----------------------- | :-----------------: | :-------------------: | :----------------------: |
|
|
139
|
+
| `SELECT` projection | ✅ | ✅ | n/a |
|
|
140
|
+
| join `ON` predicate | ✅ | ❌ | ❌ |
|
|
141
|
+
| `WHERE` | ❌ | ❌ | ❌ |
|
|
142
|
+
| `GROUP BY` / `ORDER BY` | ❌ | ❌ | n/a |
|
|
143
|
+
|
|
144
|
+
### Not implemented
|
|
145
|
+
|
|
146
|
+
Out of scope for this prototype — the SQL surface tysql does not cover yet:
|
|
147
|
+
|
|
148
|
+
| Not implemented | Notes |
|
|
149
|
+
| ----------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
150
|
+
| `LEFT` / `RIGHT` / `FULL` / `CROSS JOIN` | `INNER JOIN` only |
|
|
151
|
+
| `OR` / `NOT` / nested boolean in `WHERE` | flat conjunction (`AND`) of `Eq` only |
|
|
152
|
+
| Comparison operators other than `=` (`<`, `>`, `LIKE`, `IN`, `BETWEEN`) | `Eq` only |
|
|
153
|
+
| Aggregates other than `Count` (`SUM`, `AVG`, `MIN`, `MAX`) | — |
|
|
154
|
+
| `HAVING`, `LIMIT`, `OFFSET`, `DISTINCT` | — |
|
|
155
|
+
| Subqueries, CTEs (`WITH`), set operations (`UNION`) | — |
|
|
156
|
+
| `UPDATE` / `DELETE` statements | `CREATE TABLE`, `INSERT`, `SELECT` only |
|
|
157
|
+
| `WHERE` param type inferred from its column | declared explicitly via `Param[name, T]`, not cross-checked |
|
|
158
|
+
| `GROUP BY` functional-dependency check | not enforced (unlike PostgreSQL) |
|
|
159
|
+
|
|
160
|
+
### Examples
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from typing import Literal
|
|
164
|
+
from tysql import (
|
|
165
|
+
As, Col, Cols, Count, Eq, GroupBy, InnerJoin, On, OrderBy, Param, Select, Where, run,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# WHERE, with parameters inferred from the clause
|
|
169
|
+
Select[User, Cols[Col[User, Literal["id"]]],
|
|
170
|
+
Where[Eq[Col[User, Literal["age"]], Param[Literal["min_age"], int]]]]
|
|
171
|
+
# rows: {"id": int}; data: {"min_age": int}
|
|
172
|
+
|
|
173
|
+
# explicit INNER JOIN — columns from both tables in one row
|
|
174
|
+
Select[InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]]],
|
|
175
|
+
Cols[Col[User, Literal["email"]], Col[Post, Literal["text"]]]]
|
|
176
|
+
# rows: {"email": str, "text": str}
|
|
177
|
+
|
|
178
|
+
# aggregate + GROUP BY + ORDER BY
|
|
179
|
+
Select[InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]]],
|
|
180
|
+
Cols[Col[User, Literal["id"]], As[Count[Col[Post, Literal["id"]]], Literal["n_posts"]]],
|
|
181
|
+
GroupBy[Col[User, Literal["id"]]],
|
|
182
|
+
OrderBy[Col[User, Literal["id"]], Literal["asc"]]]
|
|
183
|
+
# rows: {"id": int, "n_posts": int}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Rendering the last statement with `tysql.render.render` yields:
|
|
187
|
+
|
|
188
|
+
```sql
|
|
189
|
+
SELECT "user"."id", count("post"."id") AS "n_posts"
|
|
190
|
+
FROM "user" INNER JOIN "post" ON "user"."id" = "post"."author"
|
|
191
|
+
GROUP BY "user"."id" ORDER BY "user"."id" ASC;
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A larger example schema lives in [`src/tysql/examples/users.py`](src/tysql/examples/users.py),
|
|
195
|
+
alongside the numbered demo snippets the [playground](https://github.com/iliyasone/tysql-playground) serves.
|
|
196
|
+
|
|
197
|
+
## Execute
|
|
198
|
+
|
|
199
|
+
Everything above is type-level; with the `tysql[psycopg]` extra, `run` also
|
|
200
|
+
executes a statement on a connection you own and returns the rows in the
|
|
201
|
+
inferred shape — SELECT as a list of dict rows, INSERT the `RETURNING` primary
|
|
202
|
+
key, `CREATE TABLE` `None`:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
import psycopg
|
|
206
|
+
from tysql import CreateTable, Insert, Select, run
|
|
207
|
+
|
|
208
|
+
with psycopg.connect("postgresql://localhost/mydb") as conn:
|
|
209
|
+
run(CreateTable[User], data=None, conn=conn)
|
|
210
|
+
new_id = run(Insert[User], data={"age": 30, "email": "a@b.c"}, conn=conn)
|
|
211
|
+
rows = run(Select[User], data=None, conn=conn) # [{"id": int, "age": int, "email": str}]
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`run` never commits — you own the connection, its transaction and pooling. For
|
|
215
|
+
asyncio, `arun` is the same contract on a psycopg `AsyncConnection`:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from tysql import arun
|
|
219
|
+
|
|
220
|
+
rows = await arun(Select[User], data=None, conn=aconn)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Development
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
uv sync --all-groups
|
|
227
|
+
|
|
228
|
+
uv run ruff check . # lint
|
|
229
|
+
uv run mypy . # static type-check — the primary type-level test layer
|
|
230
|
+
uv run pytest # runtime tests
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`mypy` is part of the test contract. Two conventions make it load-bearing:
|
|
234
|
+
|
|
235
|
+
- `mypy_test_*` functions (bodies under `if TYPE_CHECKING:`) are **not** collected
|
|
236
|
+
by pytest but **are** checked by the fork — they assert inferred types with
|
|
237
|
+
`assert_type`.
|
|
238
|
+
- `--warn-unused-ignores` is on, so every `# type: ignore[code]` is a negative
|
|
239
|
+
assertion: if the fork stops emitting that error, the run fails.
|
|
240
|
+
|
|
241
|
+
PostgreSQL integration tests (the `postgres` marker) run against a real server
|
|
242
|
+
via Docker/testcontainers; their dependencies are in the `postgres` dependency
|
|
243
|
+
group.
|
tysql-0.3.0/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# tysql
|
|
2
|
+
|
|
3
|
+
**tysql** is an experimental, type-level query builder for PostgreSQL. A SQL
|
|
4
|
+
statement is written as a _type_ — `Select[User]` — and tysql
|
|
5
|
+
|
|
6
|
+
- **statically infers the result type of every statement** and **rejects
|
|
7
|
+
ill-typed statements with a type error**, using the type operators from
|
|
8
|
+
[PEP 827](https://peps.python.org/pep-0827/);
|
|
9
|
+
- **renders the statement to PostgreSQL text** you could hand to a driver; and
|
|
10
|
+
- **executes it** on a psycopg connection you own — with the `tysql[psycopg]`
|
|
11
|
+
extra — returning rows in the inferred shape.
|
|
12
|
+
|
|
13
|
+
It is a research prototype: the API may change and SQL coverage is narrow (see [what works](#what-works))
|
|
14
|
+
|
|
15
|
+
## The idea
|
|
16
|
+
|
|
17
|
+
PEP 827 lets a type checker evaluate small type-level programs. tysql uses that
|
|
18
|
+
to make the _shape_ of a query a static fact:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import psycopg
|
|
22
|
+
from typing import Literal
|
|
23
|
+
from tysql import Col, Cols, SerialPrimaryKey, Select, Table, run
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class User(Table):
|
|
27
|
+
id: SerialPrimaryKey[int]
|
|
28
|
+
age: int
|
|
29
|
+
email: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
conn = psycopg.connect("postgresql://localhost/mydb")
|
|
33
|
+
|
|
34
|
+
# SELECT id, email FROM "user" — run on the connection you own
|
|
35
|
+
rows = run(
|
|
36
|
+
Select[User, Cols[Col[User, Literal["id"]], Col[User, Literal["email"]]]],
|
|
37
|
+
data=None,
|
|
38
|
+
conn=conn,
|
|
39
|
+
)
|
|
40
|
+
rows[0]["id"] # int — inferred
|
|
41
|
+
rows[0]["email"] # str — inferred
|
|
42
|
+
rows[0]["age"] # type error: "age" is not in the projected row
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`run`'s signature _is_ the product: `data` is typed to the statement's
|
|
46
|
+
parameters and the return type to its rows — both computed from the statement
|
|
47
|
+
type by the combinators in [`tysql/shapes.py`](src/tysql/shapes.py). Pass a
|
|
48
|
+
psycopg `conn` (the `tysql[psycopg]` extra) and it renders and executes the
|
|
49
|
+
statement, returning those rows.
|
|
50
|
+
|
|
51
|
+
Until PEP 827 lands in a released type checker, the same evaluation is done two
|
|
52
|
+
ways: at runtime by [`typemap`](https://github.com/iliyasone/python-typemap),
|
|
53
|
+
and statically by a [`mypy` fork](https://github.com/iliyasone/mypy-typemap).
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
Install the core library from PyPI with [uv](https://docs.astral.sh/uv/) or pip:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv add tysql
|
|
61
|
+
# or pip install tysql
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
That includes the runtime side: build statement types and render them to SQL. To
|
|
65
|
+
also **execute** statements against PostgreSQL, add the psycopg driver extra:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv add "tysql[psycopg]"
|
|
69
|
+
# or pip install tysql[psycopg]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Enable type-checking
|
|
73
|
+
|
|
74
|
+
The static rejection of ill-typed statements runs on a [mypy fork](https://github.com/iliyasone/mypy-typemap) that evaluates the PEP 827 combinators
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run mypy .
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Runnable demo snippets ship inside the package under
|
|
85
|
+
[`tysql/examples/`](src/tysql/examples).
|
|
86
|
+
|
|
87
|
+
## What works
|
|
88
|
+
|
|
89
|
+
Type inference below holds on **both** tracks (the `mypy` fork _and_ runtime
|
|
90
|
+
`eval_typing`); SQL rendering is runtime (`tysql.render.render`).
|
|
91
|
+
|
|
92
|
+
| Capability | Type inference | SQL rendering |
|
|
93
|
+
| ------------------------------------------------------------------------------ | :------------: | :-----------: |
|
|
94
|
+
| `CREATE TABLE` (incl. `SERIAL PRIMARY KEY`, `REFERENCES`) | — | ✅ |
|
|
95
|
+
| `INSERT` — params inferred, `RETURNING` the primary key | ✅ | ✅ |
|
|
96
|
+
| `SELECT *` — full row, primary key unwrapped (`SerialPrimaryKey[int]` → `int`) | ✅ | ✅ |
|
|
97
|
+
| `SELECT` projection — `Cols[...]` picks columns | ✅ | ✅ |
|
|
98
|
+
| Column alias — `As[col, Literal["name"]]` | ✅ | ✅ |
|
|
99
|
+
| `WHERE` — `Param`s collected into the inferred parameter mapping | ✅ | ✅ |
|
|
100
|
+
| `INNER JOIN` with an explicit `On` predicate | ✅ | ✅ |
|
|
101
|
+
| Aggregate `Count` (result typed `int`) | ✅ | ✅ |
|
|
102
|
+
| `GROUP BY` / `ORDER BY` | ✅ | ✅ |
|
|
103
|
+
|
|
104
|
+
### What it rejects
|
|
105
|
+
|
|
106
|
+
The parameter and row types are exact `TypedDict`s, so a type checker rejects, at
|
|
107
|
+
check time: a column that doesn't exist on its table
|
|
108
|
+
(`Col: no such column`); a projected column whose table isn't in the `FROM`/`JOIN`
|
|
109
|
+
(`Col: table is not in the FROM clause` — the "map the column to the table"
|
|
110
|
+
check); reading a result column that wasn't selected; and an `INSERT`/`WHERE`
|
|
111
|
+
`data` payload with a missing, extra, mis-named or wrong-typed key (the primary
|
|
112
|
+
key may not be supplied on insert).
|
|
113
|
+
|
|
114
|
+
Column checks are **per reference site**. A ✅ is enforced on both tracks; a ❌ is
|
|
115
|
+
currently **accepted** — a known false negative, not a guarantee:
|
|
116
|
+
|
|
117
|
+
| Column reference site | exists on its table | belongs to the `FROM` | operand types compatible |
|
|
118
|
+
| ----------------------- | :-----------------: | :-------------------: | :----------------------: |
|
|
119
|
+
| `SELECT` projection | ✅ | ✅ | n/a |
|
|
120
|
+
| join `ON` predicate | ✅ | ❌ | ❌ |
|
|
121
|
+
| `WHERE` | ❌ | ❌ | ❌ |
|
|
122
|
+
| `GROUP BY` / `ORDER BY` | ❌ | ❌ | n/a |
|
|
123
|
+
|
|
124
|
+
### Not implemented
|
|
125
|
+
|
|
126
|
+
Out of scope for this prototype — the SQL surface tysql does not cover yet:
|
|
127
|
+
|
|
128
|
+
| Not implemented | Notes |
|
|
129
|
+
| ----------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
130
|
+
| `LEFT` / `RIGHT` / `FULL` / `CROSS JOIN` | `INNER JOIN` only |
|
|
131
|
+
| `OR` / `NOT` / nested boolean in `WHERE` | flat conjunction (`AND`) of `Eq` only |
|
|
132
|
+
| Comparison operators other than `=` (`<`, `>`, `LIKE`, `IN`, `BETWEEN`) | `Eq` only |
|
|
133
|
+
| Aggregates other than `Count` (`SUM`, `AVG`, `MIN`, `MAX`) | — |
|
|
134
|
+
| `HAVING`, `LIMIT`, `OFFSET`, `DISTINCT` | — |
|
|
135
|
+
| Subqueries, CTEs (`WITH`), set operations (`UNION`) | — |
|
|
136
|
+
| `UPDATE` / `DELETE` statements | `CREATE TABLE`, `INSERT`, `SELECT` only |
|
|
137
|
+
| `WHERE` param type inferred from its column | declared explicitly via `Param[name, T]`, not cross-checked |
|
|
138
|
+
| `GROUP BY` functional-dependency check | not enforced (unlike PostgreSQL) |
|
|
139
|
+
|
|
140
|
+
### Examples
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from typing import Literal
|
|
144
|
+
from tysql import (
|
|
145
|
+
As, Col, Cols, Count, Eq, GroupBy, InnerJoin, On, OrderBy, Param, Select, Where, run,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# WHERE, with parameters inferred from the clause
|
|
149
|
+
Select[User, Cols[Col[User, Literal["id"]]],
|
|
150
|
+
Where[Eq[Col[User, Literal["age"]], Param[Literal["min_age"], int]]]]
|
|
151
|
+
# rows: {"id": int}; data: {"min_age": int}
|
|
152
|
+
|
|
153
|
+
# explicit INNER JOIN — columns from both tables in one row
|
|
154
|
+
Select[InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]]],
|
|
155
|
+
Cols[Col[User, Literal["email"]], Col[Post, Literal["text"]]]]
|
|
156
|
+
# rows: {"email": str, "text": str}
|
|
157
|
+
|
|
158
|
+
# aggregate + GROUP BY + ORDER BY
|
|
159
|
+
Select[InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]]],
|
|
160
|
+
Cols[Col[User, Literal["id"]], As[Count[Col[Post, Literal["id"]]], Literal["n_posts"]]],
|
|
161
|
+
GroupBy[Col[User, Literal["id"]]],
|
|
162
|
+
OrderBy[Col[User, Literal["id"]], Literal["asc"]]]
|
|
163
|
+
# rows: {"id": int, "n_posts": int}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Rendering the last statement with `tysql.render.render` yields:
|
|
167
|
+
|
|
168
|
+
```sql
|
|
169
|
+
SELECT "user"."id", count("post"."id") AS "n_posts"
|
|
170
|
+
FROM "user" INNER JOIN "post" ON "user"."id" = "post"."author"
|
|
171
|
+
GROUP BY "user"."id" ORDER BY "user"."id" ASC;
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
A larger example schema lives in [`src/tysql/examples/users.py`](src/tysql/examples/users.py),
|
|
175
|
+
alongside the numbered demo snippets the [playground](https://github.com/iliyasone/tysql-playground) serves.
|
|
176
|
+
|
|
177
|
+
## Execute
|
|
178
|
+
|
|
179
|
+
Everything above is type-level; with the `tysql[psycopg]` extra, `run` also
|
|
180
|
+
executes a statement on a connection you own and returns the rows in the
|
|
181
|
+
inferred shape — SELECT as a list of dict rows, INSERT the `RETURNING` primary
|
|
182
|
+
key, `CREATE TABLE` `None`:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
import psycopg
|
|
186
|
+
from tysql import CreateTable, Insert, Select, run
|
|
187
|
+
|
|
188
|
+
with psycopg.connect("postgresql://localhost/mydb") as conn:
|
|
189
|
+
run(CreateTable[User], data=None, conn=conn)
|
|
190
|
+
new_id = run(Insert[User], data={"age": 30, "email": "a@b.c"}, conn=conn)
|
|
191
|
+
rows = run(Select[User], data=None, conn=conn) # [{"id": int, "age": int, "email": str}]
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`run` never commits — you own the connection, its transaction and pooling. For
|
|
195
|
+
asyncio, `arun` is the same contract on a psycopg `AsyncConnection`:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from tysql import arun
|
|
199
|
+
|
|
200
|
+
rows = await arun(Select[User], data=None, conn=aconn)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Development
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
uv sync --all-groups
|
|
207
|
+
|
|
208
|
+
uv run ruff check . # lint
|
|
209
|
+
uv run mypy . # static type-check — the primary type-level test layer
|
|
210
|
+
uv run pytest # runtime tests
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`mypy` is part of the test contract. Two conventions make it load-bearing:
|
|
214
|
+
|
|
215
|
+
- `mypy_test_*` functions (bodies under `if TYPE_CHECKING:`) are **not** collected
|
|
216
|
+
by pytest but **are** checked by the fork — they assert inferred types with
|
|
217
|
+
`assert_type`.
|
|
218
|
+
- `--warn-unused-ignores` is on, so every `# type: ignore[code]` is a negative
|
|
219
|
+
assertion: if the fork stops emitting that error, the run fails.
|
|
220
|
+
|
|
221
|
+
PostgreSQL integration tests (the `postgres` marker) run against a real server
|
|
222
|
+
via Docker/testcontainers; their dependencies are in the `postgres` dependency
|
|
223
|
+
group.
|
|
@@ -19,17 +19,22 @@ license = {text = "MIT"}
|
|
|
19
19
|
name = "tysql"
|
|
20
20
|
readme = "README.md"
|
|
21
21
|
requires-python = ">=3.14"
|
|
22
|
-
version = "0.
|
|
22
|
+
version = "0.3.0"
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
# Optional runtime driver: `run(stmt, data, conn=...)` / `tysql.execute` need it.
|
|
25
|
+
# Plain psycopg on purpose — a library shouldn't force the prebuilt `[binary]`
|
|
26
|
+
# wheel on its consumers; the app picks `psycopg[binary]`/`[c]`/pure itself.
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
psycopg = [
|
|
29
|
+
"psycopg>=3.0.0",
|
|
30
|
+
]
|
|
26
31
|
|
|
27
32
|
[project.urls]
|
|
28
33
|
Homepage = "https://github.com/iliyasone/tysql"
|
|
29
34
|
Issues = "https://github.com/iliyasone/tysql/issues"
|
|
30
35
|
|
|
31
36
|
[dependency-groups]
|
|
32
|
-
# The mypy fork
|
|
37
|
+
# The mypy fork powers static type-checking (`uv run mypy`). It cannot ship as a
|
|
33
38
|
# PyPI extra (PyPI rejects direct URL dependencies), so users install it from git:
|
|
34
39
|
# uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git"
|
|
35
40
|
lint = [
|
|
@@ -46,6 +51,10 @@ test = [
|
|
|
46
51
|
|
|
47
52
|
[tool.mypy]
|
|
48
53
|
enable_error_code = ["ignore-without-code"]
|
|
54
|
+
# The bundled examples are standalone demo snippets — some carry deliberate type
|
|
55
|
+
# errors (a typo'd column, an out-of-scope reference) that are the point of the
|
|
56
|
+
# example. They ship in the wheel but are not part of this repo's type contract.
|
|
57
|
+
exclude = ["src/tysql/examples/", "scratch/"]
|
|
49
58
|
python_version = "3.14"
|
|
50
59
|
show_error_codes = true
|
|
51
60
|
warn_unused_ignores = true
|
|
@@ -58,6 +67,7 @@ python_functions = ["test_*"]
|
|
|
58
67
|
testpaths = ["tests"]
|
|
59
68
|
|
|
60
69
|
[tool.ruff]
|
|
70
|
+
extend-exclude = ["src/tysql/examples"]
|
|
61
71
|
line-length = 100
|
|
62
72
|
target-version = "py314"
|
|
63
73
|
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Statements are written as types (``Select[User]``); ``shapes`` infers the
|
|
4
4
|
parameters and result rows they carry, and ``render`` turns them into SQL text.
|
|
5
|
+
With the ``tysql[psycopg]`` extra, ``run``/``arun`` also execute them on a
|
|
6
|
+
connection the caller provides.
|
|
5
7
|
"""
|
|
6
8
|
|
|
9
|
+
from tysql.connection import PseudoConnection, SupportsCursor, pseudo_conn
|
|
10
|
+
from tysql.execute import arun, run
|
|
7
11
|
from tysql.render import render
|
|
8
|
-
from tysql.schema import Col, Column, ForeignKey, PrimaryKey, Table
|
|
9
|
-
from tysql.shapes import ParamsOf, ResultOf
|
|
12
|
+
from tysql.schema import Col, Column, ForeignKey, PrimaryKey, SerialPrimaryKey, Table
|
|
13
|
+
from tysql.shapes import ParamsOf, ResultOf
|
|
10
14
|
from tysql.statements import (
|
|
11
15
|
As,
|
|
12
16
|
Cols,
|
|
@@ -41,11 +45,16 @@ __all__ = [
|
|
|
41
45
|
"Param",
|
|
42
46
|
"ParamsOf",
|
|
43
47
|
"PrimaryKey",
|
|
48
|
+
"PseudoConnection",
|
|
44
49
|
"ResultOf",
|
|
45
50
|
"Select",
|
|
51
|
+
"SerialPrimaryKey",
|
|
46
52
|
"Statement",
|
|
53
|
+
"SupportsCursor",
|
|
47
54
|
"Table",
|
|
48
55
|
"Where",
|
|
56
|
+
"arun",
|
|
57
|
+
"pseudo_conn",
|
|
49
58
|
"render",
|
|
50
59
|
"run",
|
|
51
60
|
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The connection tysql runs against: the structural type and a stand-in.
|
|
2
|
+
|
|
3
|
+
``SupportsCursor`` is the sliver of a psycopg ``Connection`` that ``run`` needs.
|
|
4
|
+
It lives here — not in ``tysql.execute`` — so the protocol and the ``pseudo_conn``
|
|
5
|
+
stand-in stay independent of the psycopg-touching code and free of any driver.
|
|
6
|
+
|
|
7
|
+
``run`` requires a connection, but many checks only want to assert a statement's
|
|
8
|
+
inferred params and result — under ``TYPE_CHECKING`` or via ``assert_type`` —
|
|
9
|
+
without a database. Pass ``pseudo_conn`` there: it is a ``SupportsCursor`` you can
|
|
10
|
+
import and hand to ``run``, but it cannot execute — calling it raises, so a real
|
|
11
|
+
query never slips through with it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SupportsCursor(Protocol):
|
|
18
|
+
"""The sliver of a psycopg ``Connection`` that ``run`` needs."""
|
|
19
|
+
|
|
20
|
+
def cursor(self, *, row_factory: Any = ...) -> Any: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PseudoConnection:
|
|
24
|
+
"""A ``SupportsCursor`` for static assertions; it cannot run a query."""
|
|
25
|
+
|
|
26
|
+
def cursor(self, *, row_factory: Any = None) -> Any:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
"pseudo_conn is a type-checking stand-in and cannot execute a query; "
|
|
29
|
+
"pass a real psycopg connection to run()."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
#: A ready ``SupportsCursor`` to import: ``run(stmt, data, conn=pseudo_conn)``
|
|
34
|
+
#: type-checks the statement's params and result without touching a database.
|
|
35
|
+
pseudo_conn: SupportsCursor = PseudoConnection()
|
|
@@ -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, pseudo_conn, 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() executes against a connection; assert its types without one
|
|
21
|
+
rows = run(stmt, data=None, conn=pseudo_conn)
|
|
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, pseudo_conn, 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, conn=pseudo_conn) # error: Col: no such column
|