tysql 0.2.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 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.2.0"
22
+ version = "0.3.0"
23
23
 
24
- [project.scripts]
25
- tysql = "tysql.cli:main"
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 is only needed by `tysql check` / `tysql mypy`. It cannot ship as a
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 = [
@@ -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
12
  from tysql.schema import Col, Column, ForeignKey, PrimaryKey, SerialPrimaryKey, Table
9
- from tysql.shapes import ParamsOf, ResultOf, run
13
+ from tysql.shapes import ParamsOf, ResultOf
10
14
  from tysql.statements import (
11
15
  As,
12
16
  Cols,
@@ -41,12 +45,16 @@ __all__ = [
41
45
  "Param",
42
46
  "ParamsOf",
43
47
  "PrimaryKey",
48
+ "PseudoConnection",
44
49
  "ResultOf",
45
50
  "Select",
46
51
  "SerialPrimaryKey",
47
52
  "Statement",
53
+ "SupportsCursor",
48
54
  "Table",
49
55
  "Where",
56
+ "arun",
57
+ "pseudo_conn",
50
58
  "render",
51
59
  "run",
52
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()
@@ -3,7 +3,7 @@
3
3
 
4
4
  from typing import TYPE_CHECKING, Literal
5
5
 
6
- from tysql import Col, Cols, PrimaryKey, Select, Table, run
6
+ from tysql import Col, Cols, PrimaryKey, Select, Table, pseudo_conn, run
7
7
  from tysql.render import render
8
8
 
9
9
 
@@ -17,8 +17,8 @@ stmt = Select[User, Cols[Col[User, Literal["id"]], Col[User, Literal["email"]]]]
17
17
 
18
18
  print(render(stmt)) # Run renders the SQL — right in your browser
19
19
 
20
- if TYPE_CHECKING: # run() is the static contract; there is no database bridge yet
21
- rows = run(stmt, data=None)
20
+ if TYPE_CHECKING: # run() executes against a connection; assert its types without one
21
+ rows = run(stmt, data=None, conn=pseudo_conn)
22
22
  reveal_type(rows[0]["id"]) # int — inferred
23
23
  reveal_type(rows[0]["email"]) # str — inferred
24
24
  rows[0]["age"] # error: "age" was not selected
@@ -4,7 +4,7 @@
4
4
 
5
5
  from typing import TYPE_CHECKING, Literal
6
6
 
7
- from tysql import Col, Cols, PrimaryKey, Select, Table, run
7
+ from tysql import Col, Cols, PrimaryKey, Select, Table, pseudo_conn, run
8
8
  from tysql.render import render
9
9
 
10
10
 
@@ -20,4 +20,4 @@ stmt = Select[User, Cols[Col[User, Literal["emial"]]]]
20
20
  print(render(stmt)) # runtime happily renders SELECT "user"."emial" …
21
21
 
22
22
  if TYPE_CHECKING:
23
- rows = run(stmt, data=None) # error: Col: no such column
23
+ rows = run(stmt, data=None, conn=pseudo_conn) # error: Col: no such column
@@ -3,7 +3,7 @@
3
3
 
4
4
  from typing import TYPE_CHECKING, Literal
5
5
 
6
- from tysql import Col, Cols, Eq, Param, PrimaryKey, Select, Table, Where, run
6
+ from tysql import Col, Cols, Eq, Param, PrimaryKey, Select, Table, Where, pseudo_conn, run
7
7
  from tysql.render import render
8
8
 
9
9
 
@@ -22,5 +22,5 @@ stmt = Select[
22
22
  print(render(stmt)) # SELECT … WHERE "user"."age" = %(min_age)s
23
23
 
24
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
25
+ run(stmt, data={"min_age": 21}, conn=pseudo_conn) # ok
26
+ run(stmt, data={"min_age": "21"}, conn=pseudo_conn) # error: "min_age" must be int
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Literal
6
6
 
7
7
  from tysql import (
8
8
  As, Col, Cols, Count, Eq, ForeignKey, GroupBy, InnerJoin, On, OrderBy,
9
- PrimaryKey, Select, Table, run,
9
+ PrimaryKey, Select, Table, pseudo_conn, run,
10
10
  )
11
11
  from tysql.render import render
12
12
 
@@ -25,8 +25,15 @@ class Post(Table):
25
25
 
26
26
 
27
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"]]],
28
+ InnerJoin[
29
+ User,
30
+ Post,
31
+ On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]],
32
+ ],
33
+ Cols[
34
+ Col[User, Literal["id"]],
35
+ As[Count[Col[Post, Literal["id"]]], Literal["n_posts"]],
36
+ ],
30
37
  GroupBy[Col[User, Literal["id"]]],
31
38
  OrderBy[Col[User, Literal["id"]], Literal["asc"]],
32
39
  ]
@@ -34,5 +41,5 @@ stmt = Select[
34
41
  print(render(stmt))
35
42
 
36
43
  if TYPE_CHECKING:
37
- rows = run(stmt, data=None)
44
+ rows = run(stmt, data=None, conn=pseudo_conn)
38
45
  reveal_type(rows[0]["n_posts"]) # int
@@ -4,7 +4,7 @@
4
4
  from datetime import datetime
5
5
  from typing import TYPE_CHECKING, Literal
6
6
 
7
- from tysql import Col, Cols, ForeignKey, PrimaryKey, Select, Table, run
7
+ from tysql import Col, Cols, ForeignKey, PrimaryKey, Select, Table, pseudo_conn, run
8
8
  from tysql.render import render
9
9
 
10
10
 
@@ -27,4 +27,4 @@ stmt = Select[User, Cols[Col[Post, Literal["text"]]]]
27
27
  print(render(stmt)) # runtime renders SQL that selects from the wrong table
28
28
 
29
29
  if TYPE_CHECKING:
30
- rows = run(stmt, data=None) # error: Col: table is not in the FROM clause
30
+ rows = run(stmt, data=None, conn=pseudo_conn) # error: Col: table is not in the FROM clause