tysql 0.1.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/PKG-INFO ADDED
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.3
2
+ Name: tysql
3
+ Version: 0.1.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-Python: >=3.14
15
+ Project-URL: Homepage, https://github.com/iliyasone/tysql
16
+ Project-URL: Issues, https://github.com/iliyasone/tysql/issues
17
+ Description-Content-Type: text/markdown
18
+
19
+ # tysql
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
+ **tysql** is an experimental, type-level query builder for PostgreSQL. A SQL
31
+ statement is written as a *type* — `Select[User]` — and tysql
32
+
33
+ - **statically infers the result type of every statement** and **rejects
34
+ ill-typed statements with a type error**, using the type operators from
35
+ [PEP 827](https://peps.python.org/pep-0827/); and
36
+ - **renders the statement to PostgreSQL text** you could hand to a driver.
37
+
38
+ It is a research prototype: the API is unstable and SQL coverage is deliberately
39
+ narrow (see [what works](#what-works)). There is **no database bridge** — tysql
40
+ produces types and SQL strings, it does not execute anything.
41
+
42
+ ## The idea
43
+
44
+ PEP 827 lets a type checker evaluate small type-level programs. tysql uses that
45
+ to make the *shape* of a query a static fact:
46
+
47
+ ```python
48
+ from typing import Literal
49
+ from tysql import Col, Cols, PrimaryKey, Select, Table, run
50
+
51
+
52
+ class User(Table):
53
+ id: PrimaryKey[int]
54
+ age: int
55
+ email: str
56
+
57
+
58
+ # SELECT id, email FROM "user"
59
+ rows = run(Select[User, Cols[Col[User, Literal["id"]], Col[User, Literal["email"]]]], data=None)
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 parameters the
66
+ statement takes, and the return type is the list of rows it yields. Both are
67
+ computed from the statement type by the combinators in
68
+ [`tysql/shapes.py`](src/tysql/shapes.py).
69
+
70
+ Until PEP 827 lands in a released type checker, the same evaluation is done two
71
+ ways: at runtime by [`typemap`](https://github.com/iliyasone/python-typemap),
72
+ and statically by a [`mypy` fork](https://github.com/iliyasone/mypy-typemap).
73
+ tysql ships a **CLI validator** over that fork so the errors show up today,
74
+ exactly where a stock type checker would raise them once PEP 827 is standard —
75
+ see [CLI](#cli).
76
+
77
+ ## What works
78
+
79
+ Type inference below holds on **both** tracks (the `mypy` fork *and* runtime
80
+ `eval_typing`); SQL rendering is runtime (`tysql.render.render`).
81
+
82
+ | Capability | Type inference | SQL rendering |
83
+ | --- | :---: | :---: |
84
+ | `CREATE TABLE` (incl. `SERIAL PRIMARY KEY`, `REFERENCES`) | — | ✅ |
85
+ | `INSERT` — params inferred, `RETURNING` the primary key | ✅ | ✅ |
86
+ | `SELECT *` — full row, primary key unwrapped (`PrimaryKey[int]` → `int`) | ✅ | ✅ |
87
+ | `SELECT` projection — `Cols[...]` picks columns | ✅ | ✅ |
88
+ | Column alias — `As[col, Literal["name"]]` | ✅ | ✅ |
89
+ | `WHERE` — `Param`s collected into the inferred parameter mapping | ✅ | ✅ |
90
+ | `INNER JOIN` with an explicit `On` predicate | ✅ | ✅ |
91
+ | Aggregate `Count` (result typed `int`) | ✅ | ✅ |
92
+ | `GROUP BY` / `ORDER BY` | ✅ | ✅ |
93
+
94
+ ### What it rejects
95
+
96
+ The parameter and row types are exact `TypedDict`s, so a type checker (and the
97
+ CLI) reject, at check time: a column that doesn't exist on its table
98
+ (`Col: no such column`); a projected column whose table isn't in the `FROM`/`JOIN`
99
+ (`Col: table is not in the FROM clause` — the "map the column to the table"
100
+ check); reading a result column that wasn't selected; and an `INSERT`/`WHERE`
101
+ `data` payload with a missing, extra, mis-named or wrong-typed key (the primary
102
+ key may not be supplied on insert).
103
+
104
+ Column checks are **per reference site**. A ✅ is enforced on both tracks; a ❌ is
105
+ currently **accepted** — a known false negative, not a guarantee:
106
+
107
+ | Column reference site | exists on its table | belongs to the `FROM` | operand types compatible |
108
+ | --- | :---: | :---: | :---: |
109
+ | `SELECT` projection | ✅ | ✅ | n/a |
110
+ | join `ON` predicate | ✅ | ❌ | ❌ |
111
+ | `WHERE` | ❌ | ❌ | ❌ |
112
+ | `GROUP BY` / `ORDER BY` | ❌ | ❌ | n/a |
113
+
114
+ ### Not implemented
115
+
116
+ Out of scope for this prototype — tysql produces types and SQL text only, and no
117
+ database is contacted:
118
+
119
+ | Not implemented | Notes |
120
+ | --- | --- |
121
+ | Query execution / database bridge | `run` raises `NotImplementedError`; use `render` for SQL text |
122
+ | `LEFT` / `RIGHT` / `FULL` / `CROSS JOIN` | `INNER JOIN` only |
123
+ | `OR` / `NOT` / nested boolean in `WHERE` | flat conjunction (`AND`) of `Eq` only |
124
+ | Comparison operators other than `=` (`<`, `>`, `LIKE`, `IN`, `BETWEEN`) | `Eq` only |
125
+ | Aggregates other than `Count` (`SUM`, `AVG`, `MIN`, `MAX`) | — |
126
+ | `HAVING`, `LIMIT`, `OFFSET`, `DISTINCT` | — |
127
+ | Subqueries, CTEs (`WITH`), set operations (`UNION`) | — |
128
+ | `UPDATE` / `DELETE` statements | `CREATE TABLE`, `INSERT`, `SELECT` only |
129
+ | `WHERE` param type inferred from its column | declared explicitly via `Param[name, T]`, not cross-checked |
130
+ | `GROUP BY` functional-dependency check | not enforced (unlike PostgreSQL) |
131
+
132
+ ### Examples
133
+
134
+ ```python
135
+ from typing import Literal
136
+ from tysql import (
137
+ As, Col, Cols, Count, Eq, GroupBy, InnerJoin, On, OrderBy, Param, Select, Where, run,
138
+ )
139
+
140
+ # WHERE, with parameters inferred from the clause
141
+ Select[User, Cols[Col[User, Literal["id"]]],
142
+ Where[Eq[Col[User, Literal["age"]], Param[Literal["min_age"], int]]]]
143
+ # rows: {"id": int}; data: {"min_age": int}
144
+
145
+ # explicit INNER JOIN — columns from both tables in one row
146
+ Select[InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]]],
147
+ Cols[Col[User, Literal["email"]], Col[Post, Literal["text"]]]]
148
+ # rows: {"email": str, "text": str}
149
+
150
+ # aggregate + GROUP BY + ORDER BY
151
+ Select[InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]]],
152
+ Cols[Col[User, Literal["id"]], As[Count[Col[Post, Literal["id"]]], Literal["n_posts"]]],
153
+ GroupBy[Col[User, Literal["id"]]],
154
+ OrderBy[Col[User, Literal["id"]], Literal["asc"]]]
155
+ # rows: {"id": int, "n_posts": int}
156
+ ```
157
+
158
+ Rendering the last statement with `tysql.render.render` yields:
159
+
160
+ ```sql
161
+ SELECT "user"."id", count("post"."id") AS "n_posts"
162
+ FROM "user" INNER JOIN "post" ON "user"."id" = "post"."author"
163
+ GROUP BY "user"."id" ORDER BY "user"."id" ASC;
164
+ ```
165
+
166
+ A larger example schema lives in [`src/examples/users.py`](src/examples/users.py).
167
+
168
+ ## CLI
169
+
170
+ Installing tysql provides a `tysql` command that wraps the pinned `mypy` fork, so
171
+ PEP 827 combinator types resolve to real types instead of `Any`. The fork cannot
172
+ be a PyPI dependency (direct URL), so install it next to tysql from GitHub:
173
+ `uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git"`.
174
+
175
+ ```bash
176
+ tysql check [PATH ...] # type-check paths (default: .) — rejects ill-typed statements
177
+ tysql mypy [ARG ...] # forward arguments straight to the fork's mypy
178
+ ```
179
+
180
+ `tysql check some_file.py` reports, for instance, `Col: no such column` when a
181
+ statement references a column that does not exist — the same error a type
182
+ checker will emit once PEP 827 is standard.
183
+
184
+ ## Development
185
+
186
+ ```bash
187
+ uv sync --all-groups
188
+
189
+ uv run ruff check . # lint
190
+ uv run mypy . # static type-check — the primary type-level test layer
191
+ uv run pytest # runtime tests
192
+ ```
193
+
194
+ `mypy` is part of the test contract. Two conventions make it load-bearing:
195
+
196
+ - `mypy_test_*` functions (bodies under `if TYPE_CHECKING:`) are **not** collected
197
+ by pytest but **are** checked by the fork — they assert inferred types with
198
+ `assert_type`.
199
+ - `--warn-unused-ignores` is on, so every `# type: ignore[code]` is a negative
200
+ assertion: if the fork stops emitting that error, the run fails.
201
+
202
+ PostgreSQL integration dependencies (for a future execution bridge) are staged
203
+ in the `postgres` dependency group.
tysql-0.1.0/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # tysql
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
+ **tysql** is an experimental, type-level query builder for PostgreSQL. A SQL
13
+ statement is written as a *type* — `Select[User]` — and tysql
14
+
15
+ - **statically infers the result type of every statement** and **rejects
16
+ ill-typed statements with a type error**, using the type operators from
17
+ [PEP 827](https://peps.python.org/pep-0827/); and
18
+ - **renders the statement to PostgreSQL text** you could hand to a driver.
19
+
20
+ It is a research prototype: the API is unstable and SQL coverage is deliberately
21
+ narrow (see [what works](#what-works)). There is **no database bridge** — tysql
22
+ produces types and SQL strings, it does not execute anything.
23
+
24
+ ## The idea
25
+
26
+ PEP 827 lets a type checker evaluate small type-level programs. tysql uses that
27
+ to make the *shape* of a query a static fact:
28
+
29
+ ```python
30
+ from typing import Literal
31
+ from tysql import Col, Cols, PrimaryKey, Select, Table, run
32
+
33
+
34
+ class User(Table):
35
+ id: PrimaryKey[int]
36
+ age: int
37
+ email: str
38
+
39
+
40
+ # SELECT id, email FROM "user"
41
+ rows = run(Select[User, Cols[Col[User, Literal["id"]], Col[User, Literal["email"]]]], data=None)
42
+ rows[0]["id"] # int — inferred
43
+ rows[0]["email"] # str — inferred
44
+ rows[0]["age"] # type error: "age" is not in the projected row
45
+ ```
46
+
47
+ `run`'s signature *is* the product: `data` is typed to the parameters the
48
+ statement takes, and the return type is the list of rows it yields. Both are
49
+ computed from the statement type by the combinators in
50
+ [`tysql/shapes.py`](src/tysql/shapes.py).
51
+
52
+ Until PEP 827 lands in a released type checker, the same evaluation is done two
53
+ ways: at runtime by [`typemap`](https://github.com/iliyasone/python-typemap),
54
+ and statically by a [`mypy` fork](https://github.com/iliyasone/mypy-typemap).
55
+ tysql ships a **CLI validator** over that fork so the errors show up today,
56
+ exactly where a stock type checker would raise them once PEP 827 is standard —
57
+ see [CLI](#cli).
58
+
59
+ ## What works
60
+
61
+ Type inference below holds on **both** tracks (the `mypy` fork *and* runtime
62
+ `eval_typing`); SQL rendering is runtime (`tysql.render.render`).
63
+
64
+ | Capability | Type inference | SQL rendering |
65
+ | --- | :---: | :---: |
66
+ | `CREATE TABLE` (incl. `SERIAL PRIMARY KEY`, `REFERENCES`) | — | ✅ |
67
+ | `INSERT` — params inferred, `RETURNING` the primary key | ✅ | ✅ |
68
+ | `SELECT *` — full row, primary key unwrapped (`PrimaryKey[int]` → `int`) | ✅ | ✅ |
69
+ | `SELECT` projection — `Cols[...]` picks columns | ✅ | ✅ |
70
+ | Column alias — `As[col, Literal["name"]]` | ✅ | ✅ |
71
+ | `WHERE` — `Param`s collected into the inferred parameter mapping | ✅ | ✅ |
72
+ | `INNER JOIN` with an explicit `On` predicate | ✅ | ✅ |
73
+ | Aggregate `Count` (result typed `int`) | ✅ | ✅ |
74
+ | `GROUP BY` / `ORDER BY` | ✅ | ✅ |
75
+
76
+ ### What it rejects
77
+
78
+ The parameter and row types are exact `TypedDict`s, so a type checker (and the
79
+ CLI) reject, at check time: a column that doesn't exist on its table
80
+ (`Col: no such column`); a projected column whose table isn't in the `FROM`/`JOIN`
81
+ (`Col: table is not in the FROM clause` — the "map the column to the table"
82
+ check); reading a result column that wasn't selected; and an `INSERT`/`WHERE`
83
+ `data` payload with a missing, extra, mis-named or wrong-typed key (the primary
84
+ key may not be supplied on insert).
85
+
86
+ Column checks are **per reference site**. A ✅ is enforced on both tracks; a ❌ is
87
+ currently **accepted** — a known false negative, not a guarantee:
88
+
89
+ | Column reference site | exists on its table | belongs to the `FROM` | operand types compatible |
90
+ | --- | :---: | :---: | :---: |
91
+ | `SELECT` projection | ✅ | ✅ | n/a |
92
+ | join `ON` predicate | ✅ | ❌ | ❌ |
93
+ | `WHERE` | ❌ | ❌ | ❌ |
94
+ | `GROUP BY` / `ORDER BY` | ❌ | ❌ | n/a |
95
+
96
+ ### Not implemented
97
+
98
+ Out of scope for this prototype — tysql produces types and SQL text only, and no
99
+ database is contacted:
100
+
101
+ | Not implemented | Notes |
102
+ | --- | --- |
103
+ | Query execution / database bridge | `run` raises `NotImplementedError`; use `render` for SQL text |
104
+ | `LEFT` / `RIGHT` / `FULL` / `CROSS JOIN` | `INNER JOIN` only |
105
+ | `OR` / `NOT` / nested boolean in `WHERE` | flat conjunction (`AND`) of `Eq` only |
106
+ | Comparison operators other than `=` (`<`, `>`, `LIKE`, `IN`, `BETWEEN`) | `Eq` only |
107
+ | Aggregates other than `Count` (`SUM`, `AVG`, `MIN`, `MAX`) | — |
108
+ | `HAVING`, `LIMIT`, `OFFSET`, `DISTINCT` | — |
109
+ | Subqueries, CTEs (`WITH`), set operations (`UNION`) | — |
110
+ | `UPDATE` / `DELETE` statements | `CREATE TABLE`, `INSERT`, `SELECT` only |
111
+ | `WHERE` param type inferred from its column | declared explicitly via `Param[name, T]`, not cross-checked |
112
+ | `GROUP BY` functional-dependency check | not enforced (unlike PostgreSQL) |
113
+
114
+ ### Examples
115
+
116
+ ```python
117
+ from typing import Literal
118
+ from tysql import (
119
+ As, Col, Cols, Count, Eq, GroupBy, InnerJoin, On, OrderBy, Param, Select, Where, run,
120
+ )
121
+
122
+ # WHERE, with parameters inferred from the clause
123
+ Select[User, Cols[Col[User, Literal["id"]]],
124
+ Where[Eq[Col[User, Literal["age"]], Param[Literal["min_age"], int]]]]
125
+ # rows: {"id": int}; data: {"min_age": int}
126
+
127
+ # explicit INNER JOIN — columns from both tables in one row
128
+ Select[InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]]],
129
+ Cols[Col[User, Literal["email"]], Col[Post, Literal["text"]]]]
130
+ # rows: {"email": str, "text": str}
131
+
132
+ # aggregate + GROUP BY + ORDER BY
133
+ Select[InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]], Col[Post, Literal["author"]]]]],
134
+ Cols[Col[User, Literal["id"]], As[Count[Col[Post, Literal["id"]]], Literal["n_posts"]]],
135
+ GroupBy[Col[User, Literal["id"]]],
136
+ OrderBy[Col[User, Literal["id"]], Literal["asc"]]]
137
+ # rows: {"id": int, "n_posts": int}
138
+ ```
139
+
140
+ Rendering the last statement with `tysql.render.render` yields:
141
+
142
+ ```sql
143
+ SELECT "user"."id", count("post"."id") AS "n_posts"
144
+ FROM "user" INNER JOIN "post" ON "user"."id" = "post"."author"
145
+ GROUP BY "user"."id" ORDER BY "user"."id" ASC;
146
+ ```
147
+
148
+ A larger example schema lives in [`src/examples/users.py`](src/examples/users.py).
149
+
150
+ ## CLI
151
+
152
+ Installing tysql provides a `tysql` command that wraps the pinned `mypy` fork, so
153
+ PEP 827 combinator types resolve to real types instead of `Any`. The fork cannot
154
+ be a PyPI dependency (direct URL), so install it next to tysql from GitHub:
155
+ `uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git"`.
156
+
157
+ ```bash
158
+ tysql check [PATH ...] # type-check paths (default: .) — rejects ill-typed statements
159
+ tysql mypy [ARG ...] # forward arguments straight to the fork's mypy
160
+ ```
161
+
162
+ `tysql check some_file.py` reports, for instance, `Col: no such column` when a
163
+ statement references a column that does not exist — the same error a type
164
+ checker will emit once PEP 827 is standard.
165
+
166
+ ## Development
167
+
168
+ ```bash
169
+ uv sync --all-groups
170
+
171
+ uv run ruff check . # lint
172
+ uv run mypy . # static type-check — the primary type-level test layer
173
+ uv run pytest # runtime tests
174
+ ```
175
+
176
+ `mypy` is part of the test contract. Two conventions make it load-bearing:
177
+
178
+ - `mypy_test_*` functions (bodies under `if TYPE_CHECKING:`) are **not** collected
179
+ by pytest but **are** checked by the fork — they assert inferred types with
180
+ `assert_type`.
181
+ - `--warn-unused-ignores` is on, so every `# type: ignore[code]` is a negative
182
+ assertion: if the fork stops emitting that error, the run fails.
183
+
184
+ PostgreSQL integration dependencies (for a future execution bridge) are staged
185
+ in the `postgres` dependency group.
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ build-backend = "uv_build"
3
+ requires = ["uv_build>=0.9.11,<0.12.0"]
4
+
5
+ [project]
6
+ authors = [{name = "Ilias Dzhabbarov", email = "iliyas.dzabbarov@gmail.com"}]
7
+ classifiers = [
8
+ "Development Status :: 2 - Pre-Alpha",
9
+ "Programming Language :: Python :: 3",
10
+ "Programming Language :: Python :: 3.14",
11
+ "Typing :: Typed",
12
+ ]
13
+ dependencies = [
14
+ "python-typemap>=0.1.0",
15
+ ]
16
+ description = "Experimental, type-level PostgreSQL query builder prototype using PEP 827 typemaps."
17
+ keywords = ["mypy", "pep-827", "postgresql", "query-builder", "typemap", "typing"]
18
+ license = {text = "MIT"}
19
+ name = "tysql"
20
+ readme = "README.md"
21
+ requires-python = ">=3.14"
22
+ version = "0.1.0"
23
+
24
+ [project.scripts]
25
+ tysql = "tysql.cli:main"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/iliyasone/tysql"
29
+ Issues = "https://github.com/iliyasone/tysql/issues"
30
+
31
+ [dependency-groups]
32
+ # The mypy fork is only needed by `tysql check` / `tysql mypy`. It cannot ship as a
33
+ # PyPI extra (PyPI rejects direct URL dependencies), so users install it from git:
34
+ # uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git"
35
+ lint = [
36
+ "mypy @ git+https://github.com/iliyasone/mypy-typemap.git",
37
+ "ruff>=0.14.7",
38
+ ]
39
+ postgres = [
40
+ "psycopg[binary]>=3.0.0",
41
+ "testcontainers>=4.0.0",
42
+ ]
43
+ test = [
44
+ "pytest>=9.0.3",
45
+ ]
46
+
47
+ [tool.mypy]
48
+ enable_error_code = ["ignore-without-code"]
49
+ python_version = "3.14"
50
+ show_error_codes = true
51
+ warn_unused_ignores = true
52
+
53
+ [tool.pytest.ini_options]
54
+ markers = [
55
+ "postgres: integration tests that require Docker and PostgreSQL",
56
+ ]
57
+ python_functions = ["test_*"]
58
+ testpaths = ["tests"]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py314"
63
+
64
+ [tool.ruff.lint]
65
+ select = ["B", "E", "F", "I", "RUF", "UP"]
@@ -0,0 +1,51 @@
1
+ """tysql: a type-level PostgreSQL query builder prototype.
2
+
3
+ Statements are written as types (``Select[User]``); ``shapes`` infers the
4
+ parameters and result rows they carry, and ``render`` turns them into SQL text.
5
+ """
6
+
7
+ from tysql.render import render
8
+ from tysql.schema import Col, Column, ForeignKey, PrimaryKey, Table
9
+ from tysql.shapes import ParamsOf, ResultOf, run
10
+ from tysql.statements import (
11
+ As,
12
+ Cols,
13
+ Count,
14
+ CreateTable,
15
+ Eq,
16
+ GroupBy,
17
+ InnerJoin,
18
+ Insert,
19
+ On,
20
+ OrderBy,
21
+ Param,
22
+ Select,
23
+ Statement,
24
+ Where,
25
+ )
26
+
27
+ __all__ = [
28
+ "As",
29
+ "Col",
30
+ "Cols",
31
+ "Column",
32
+ "Count",
33
+ "CreateTable",
34
+ "Eq",
35
+ "ForeignKey",
36
+ "GroupBy",
37
+ "InnerJoin",
38
+ "Insert",
39
+ "On",
40
+ "OrderBy",
41
+ "Param",
42
+ "ParamsOf",
43
+ "PrimaryKey",
44
+ "ResultOf",
45
+ "Select",
46
+ "Statement",
47
+ "Table",
48
+ "Where",
49
+ "render",
50
+ "run",
51
+ ]
@@ -0,0 +1,4 @@
1
+ from tysql.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,101 @@
1
+ """Command-line entry point for tysql.
2
+
3
+ Two subcommands, both backed by the pinned mypy fork (`msullivan/mypy-typemap`)
4
+ that evaluates PEP 827 combinators natively:
5
+
6
+ - ``tysql check [PATH ...]`` - type-check the given paths (default: ``.``) with
7
+ tysql-appropriate defaults. This is the friendly command most users want.
8
+ - ``tysql mypy [ARG ...]`` - forward every argument verbatim to the fork's
9
+ mypy. The escape hatch for full mypy control.
10
+
11
+ Both run ``python -m mypy`` in the *current* interpreter, so the mypy that gets
12
+ used is whichever one tysql was installed alongside. The fork cannot ship as a
13
+ PyPI dependency (direct URL), so it is installed from git next to tysql; that is
14
+ what makes the combinator types resolve to real types instead of ``Any``.
15
+ """
16
+
17
+ import subprocess
18
+ import sys
19
+ from collections.abc import Sequence
20
+ from importlib.util import find_spec
21
+ from pathlib import Path
22
+
23
+ # Marker file that only the mypy fork ships in its bundled typeshed. Its presence
24
+ # is how we tell "our mypy" apart from a stock one.
25
+ _FORK_MARKER = Path("typeshed", "stdlib", "_typeshed", "typemap.pyi")
26
+
27
+ # Defaults layered under `tysql check` before the user's own paths/flags.
28
+ _CHECK_DEFAULTS = ("--show-error-codes",)
29
+
30
+ _USAGE = """\
31
+ usage: tysql <command> [args]
32
+
33
+ commands:
34
+ check [PATH ...] type-check paths with the tysql mypy fork (default: .)
35
+ mypy [ARG ...] forward all arguments straight to the mypy fork
36
+
37
+ Run `tysql mypy --help` for the full set of mypy flags.
38
+ """
39
+
40
+
41
+ def _is_fork() -> bool:
42
+ """True when the importable mypy looks like the tysql fork.
43
+
44
+ Uses ``find_spec`` so we never import (and pay the startup cost of) mypy just
45
+ to answer the question.
46
+ """
47
+ spec = find_spec("mypy")
48
+ if spec is None or spec.origin is None:
49
+ return False
50
+ return (Path(spec.origin).parent / _FORK_MARKER).is_file()
51
+
52
+
53
+ def _warn_if_not_fork() -> None:
54
+ if not _is_fork():
55
+ print(
56
+ "tysql: warning: no tysql mypy fork found; PEP 827 combinator types "
57
+ "will collapse to Any. Install the fork from GitHub, e.g.\n"
58
+ ' uv add "mypy @ git+https://github.com/iliyasone/mypy-typemap.git"',
59
+ file=sys.stderr,
60
+ )
61
+
62
+
63
+ def _run_mypy(args: Sequence[str]) -> int:
64
+ """Invoke the fork's mypy in this interpreter, streaming its output."""
65
+ completed = subprocess.run([sys.executable, "-m", "mypy", *args])
66
+ return completed.returncode
67
+
68
+
69
+ def _cmd_mypy(args: Sequence[str]) -> int:
70
+ _warn_if_not_fork()
71
+ return _run_mypy(args)
72
+
73
+
74
+ def _cmd_check(args: Sequence[str]) -> int:
75
+ _warn_if_not_fork()
76
+ # No arguments at all -> check the current tree. Once the user passes
77
+ # anything (paths and/or extra mypy flags) we forward it untouched.
78
+ targets = list(args) if args else ["."]
79
+ return _run_mypy([*_CHECK_DEFAULTS, *targets])
80
+
81
+
82
+ def main(argv: Sequence[str] | None = None) -> int:
83
+ argv = list(sys.argv[1:] if argv is None else argv)
84
+
85
+ if not argv or argv[0] in ("-h", "--help"):
86
+ sys.stdout.write(_USAGE)
87
+ return 0 if argv else 2
88
+
89
+ command, rest = argv[0], argv[1:]
90
+ if command == "check":
91
+ return _cmd_check(rest)
92
+ if command == "mypy":
93
+ return _cmd_mypy(rest)
94
+
95
+ sys.stderr.write(f"tysql: unknown command {command!r}\n\n")
96
+ sys.stderr.write(_USAGE)
97
+ return 2
98
+
99
+
100
+ if __name__ == "__main__":
101
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,202 @@
1
+ """Render a statement type to PostgreSQL text.
2
+
3
+ This is the runtime companion to ``tysql.shapes``: the shapes track says what a
4
+ statement *returns*, this says what it *is*. It walks the statement type with
5
+ ``typing.get_origin`` / ``get_args`` and emits SQL a person would recognise --
6
+ qualified identifiers, ``psycopg`` named placeholders (``%(name)s``), and a
7
+ ``RETURNING`` clause on inserts. It does not talk to a database; there is no
8
+ connection here, only text.
9
+ """
10
+
11
+ from datetime import datetime
12
+ from typing import Any, get_args, get_origin
13
+
14
+ from tysql.schema import Column, ForeignKey, PrimaryKey
15
+ from tysql.statements import (
16
+ As,
17
+ Cols,
18
+ Count,
19
+ CreateTable,
20
+ GroupBy,
21
+ InnerJoin,
22
+ Insert,
23
+ On,
24
+ OrderBy,
25
+ Param,
26
+ Select,
27
+ Where,
28
+ )
29
+
30
+ # Python value type -> PostgreSQL column type.
31
+ _PG_TYPES: dict[Any, str] = {
32
+ int: "INTEGER",
33
+ str: "TEXT",
34
+ bool: "BOOLEAN",
35
+ float: "DOUBLE PRECISION",
36
+ bytes: "BYTEA",
37
+ datetime: "TIMESTAMP",
38
+ }
39
+
40
+
41
+ def render(stmt: Any) -> str:
42
+ """Render a statement type such as ``Select[User]`` to a PostgreSQL string."""
43
+ origin = get_origin(stmt)
44
+ if origin is CreateTable:
45
+ return _render_create(stmt)
46
+ if origin is Insert:
47
+ return _render_insert(stmt)
48
+ if origin is Select:
49
+ return _render_select(stmt)
50
+ raise ValueError(f"render: unsupported statement {stmt!r}")
51
+
52
+
53
+ # --- identifiers and small parts --------------------------------------------
54
+
55
+
56
+ def _ident(name: str) -> str:
57
+ return f'"{name}"'
58
+
59
+
60
+ def _table_ref(table: type) -> str:
61
+ return _ident(table.__name__.lower())
62
+
63
+
64
+ def _literal(lit: Any) -> Any:
65
+ """The single value inside a ``Literal[...]``."""
66
+ return get_args(lit)[0]
67
+
68
+
69
+ def _column_ref(col: Any) -> str:
70
+ """``Col[User, Literal["id"]]`` -> ``"user"."id"``."""
71
+ owner, name_literal = get_args(col)[:2]
72
+ return f"{_table_ref(owner)}.{_ident(_literal(name_literal))}"
73
+
74
+
75
+ def _expr(expr: Any) -> str:
76
+ """A projected item or predicate operand: a column, ``count(...)`` or a placeholder."""
77
+ origin = get_origin(expr)
78
+ if origin is Count:
79
+ return f"count({_column_ref(get_args(expr)[0])})"
80
+ if origin is Param:
81
+ return f"%({_literal(get_args(expr)[0])})s"
82
+ return _column_ref(expr)
83
+
84
+
85
+ def _projection_item(item: Any) -> str:
86
+ if get_origin(item) is As:
87
+ expr, alias = get_args(item)
88
+ return f"{_expr(expr)} AS {_ident(_literal(alias))}"
89
+ return _expr(item)
90
+
91
+
92
+ def _predicate(pred: Any) -> str:
93
+ left, right = get_args(pred)
94
+ return f"{_expr(left)} = {_expr(right)}"
95
+
96
+
97
+ def _base_pg_type(annotation: Any) -> str:
98
+ return _PG_TYPES.get(annotation, "TEXT")
99
+
100
+
101
+ # --- FROM / joins -----------------------------------------------------------
102
+
103
+
104
+ def _from(source: Any) -> str:
105
+ if get_origin(source) is InnerJoin:
106
+ left, right, *mods = get_args(source)
107
+ clause = f"{_table_ref(left)} INNER JOIN {_table_ref(right)}"
108
+ on = _find(mods, On)
109
+ if on is not None:
110
+ clause += f" ON {_predicate(get_args(on)[0])}"
111
+ return clause
112
+ return _table_ref(source)
113
+
114
+
115
+ def _find(mods: tuple[Any, ...] | list[Any], combinator: Any) -> Any:
116
+ """The first modifier whose origin is ``combinator``, or ``None``."""
117
+ for mod in mods:
118
+ if get_origin(mod) is combinator:
119
+ return mod
120
+ return None
121
+
122
+
123
+ # --- statements -------------------------------------------------------------
124
+
125
+
126
+ def _columns(table: type) -> list[Column[Any, Any, Any]]:
127
+ return list(table.__tysql_columns__.values()) # type: ignore[attr-defined]
128
+
129
+
130
+ def _is_primary_key(annotation: Any) -> bool:
131
+ return get_origin(annotation) is PrimaryKey
132
+
133
+
134
+ def _column_ddl(column: Column[Any, Any, Any]) -> str:
135
+ annotation = column.type
136
+ origin = get_origin(annotation)
137
+ if origin is PrimaryKey:
138
+ inner = get_args(annotation)[0]
139
+ kind = "SERIAL PRIMARY KEY" if inner is int else f"{_base_pg_type(inner)} PRIMARY KEY"
140
+ return f"{_ident(column.name)} {kind}"
141
+ if origin is ForeignKey:
142
+ ref, name_literal = get_args(annotation)
143
+ name = _literal(name_literal)
144
+ referenced = ref.__tysql_columns__[name].type
145
+ if get_origin(referenced) is PrimaryKey:
146
+ referenced = get_args(referenced)[0]
147
+ return (
148
+ f"{_ident(column.name)} {_base_pg_type(referenced)} NOT NULL "
149
+ f"REFERENCES {_table_ref(ref)}({_ident(name)})"
150
+ )
151
+ return f"{_ident(column.name)} {_base_pg_type(annotation)} NOT NULL"
152
+
153
+
154
+ def _render_create(stmt: Any) -> str:
155
+ table = get_args(stmt)[0]
156
+ lines = ",\n".join(f" {_column_ddl(c)}" for c in _columns(table))
157
+ return f"CREATE TABLE {_table_ref(table)} (\n{lines}\n);"
158
+
159
+
160
+ def _render_insert(stmt: Any) -> str:
161
+ table = get_args(stmt)[0]
162
+ inserted = [c for c in _columns(table) if not _is_primary_key(c.type)]
163
+ keys = [c for c in _columns(table) if _is_primary_key(c.type)]
164
+ names = ", ".join(_ident(c.name) for c in inserted)
165
+ placeholders = ", ".join(f"%({c.name})s" for c in inserted)
166
+ sql = f"INSERT INTO {_table_ref(table)} ({names}) VALUES ({placeholders})"
167
+ if keys:
168
+ sql += " RETURNING " + ", ".join(_ident(c.name) for c in keys)
169
+ return sql + ";"
170
+
171
+
172
+ def _star_projection(source: Any) -> str:
173
+ if get_origin(source) is InnerJoin:
174
+ return "*"
175
+ return ", ".join(f"{_table_ref(source)}.{_ident(c.name)}" for c in _columns(source))
176
+
177
+
178
+ def _render_select(stmt: Any) -> str:
179
+ source, *mods = get_args(stmt)
180
+
181
+ cols = _find(mods, Cols)
182
+ if cols is not None:
183
+ projection = ", ".join(_projection_item(c) for c in get_args(cols))
184
+ else:
185
+ projection = _star_projection(source)
186
+
187
+ sql = f"SELECT {projection} FROM {_from(source)}"
188
+
189
+ where = _find(mods, Where)
190
+ if where is not None:
191
+ sql += " WHERE " + " AND ".join(_predicate(p) for p in get_args(where))
192
+
193
+ group_by = _find(mods, GroupBy)
194
+ if group_by is not None:
195
+ sql += " GROUP BY " + ", ".join(_column_ref(c) for c in get_args(group_by))
196
+
197
+ order_by = _find(mods, OrderBy)
198
+ if order_by is not None:
199
+ column, direction = get_args(order_by)
200
+ sql += f" ORDER BY {_column_ref(column)} {str(_literal(direction)).upper()}"
201
+
202
+ return sql + ";"
@@ -0,0 +1,64 @@
1
+ import sys
2
+ from typing import Any, Literal, Never, get_type_hints
3
+
4
+ if sys.version_info >= (3, 16):
5
+ # When PEP 827 lands in the stdlib (hoped for 3.16+), the combinators live in `typing` itself.
6
+ import typing
7
+ else:
8
+ import typemap_extensions as typing
9
+
10
+
11
+ class Column[Owner, Name, T]:
12
+ """Column reference carrying owner, column name, and value type."""
13
+
14
+ def __init__(self, owner: type, name: str, type_: Any) -> None:
15
+ self.owner = owner
16
+ self.name = name
17
+ self.type = type_
18
+
19
+ def __repr__(self) -> str:
20
+ type_name = getattr(self.type, "__name__", repr(self.type))
21
+ return f"Column({self.owner.__name__}.{self.name}: {type_name})"
22
+
23
+
24
+ class Table:
25
+ def __init_subclass__[T](
26
+ cls: type[T],
27
+ ) -> typing.UpdateClass[
28
+ *[typing.Member[x.name, Column[T, x.name, x.type]] for x in typing.Iter[typing.Attrs[T]]]
29
+ ]:
30
+ # TODO: validate types
31
+ columns: dict[str, Column[Any, Any, Any]] = {}
32
+ for name, annotation in get_type_hints(cls).items():
33
+ column: Column[Any, Any, Any] = Column(cls, name, annotation)
34
+ columns[name] = column
35
+ setattr(cls, name, column)
36
+ cls.__tysql_columns__ = columns # type: ignore[attr-defined]
37
+ return None # type: ignore[return-value]
38
+
39
+
40
+ # A column reference: Col[User, Literal["id"]] resolves to the Column that the
41
+ # Table metaclass injected for `id`, or a "no such column" type error.
42
+ #
43
+ # This is a flat two-way conditional on purpose. A chained `A if .. else B if ..
44
+ # else C` makes the static fork eagerly evaluate the deepest branch even when it
45
+ # is not selected, so a RaiseError in a trailing `else` fires for *valid*
46
+ # columns. Keeping the RaiseError in the taken-only branch avoids that.
47
+ type Col[C, N] = (
48
+ typing.RaiseError[Literal["Col: no such column"]]
49
+ if typing.IsEquivalent[typing.GetMemberType[C, N], Never]
50
+ else typing.GetMemberType[C, N]
51
+ )
52
+
53
+
54
+ class PrimaryKey[T]:
55
+ """Marks a column as the table's primary key; unwrapped to ``T`` in results."""
56
+
57
+
58
+ class ForeignKey[Owner, Name]:
59
+ """A foreign key referencing ``Owner``'s column ``Name``.
60
+
61
+ Written ``ForeignKey[User, Literal["id"]]`` rather than nesting a ``Col``,
62
+ so the reference stays a plain type argument (a conditional ``Col`` in
63
+ annotation position would eagerly trip its own "no such column" guard).
64
+ """
@@ -0,0 +1,223 @@
1
+ """Type-level shapes: from a statement type to its parameters and result rows.
2
+
3
+ Each combinator here is a PEP 827 type function. The runtime track evaluates
4
+ them with ``typemap.type_eval.eval_typing``; the static track evaluates the very
5
+ same aliases inside the ``mypy-typemap`` fork. Both must agree, which shapes how
6
+ these are written:
7
+
8
+ - only conditionals (``A if Cond else B``) and *single* comprehensions are used;
9
+ the fork does not support recursive aliases or nested ``for`` comprehensions;
10
+ - ``RaiseError`` branches stay on the value-position path (through ``run`` /
11
+ ``eval_typing``), never in bare annotations, where the fork would force them.
12
+ """
13
+
14
+ import sys
15
+ from typing import Any, Literal
16
+
17
+ from tysql.schema import Column, PrimaryKey
18
+ from tysql.statements import (
19
+ As,
20
+ Cols,
21
+ Count,
22
+ CreateTable,
23
+ Eq,
24
+ InnerJoin,
25
+ Insert,
26
+ Param,
27
+ Select,
28
+ Statement,
29
+ Where,
30
+ )
31
+
32
+ if sys.version_info >= (3, 16):
33
+ # When PEP 827 lands in the stdlib (hoped for 3.16+), the combinators live in `typing` itself.
34
+ import typing
35
+ else:
36
+ import typemap_extensions as typing
37
+
38
+
39
+ # A table type -> the TypedDict you pass to INSERT: every column except the
40
+ # primary key, which the database fills in.
41
+ type Row[T] = typing.NewTypedDict[
42
+ *[
43
+ typing.Member[x.name, typing.GetArg[x.type, Column, Literal[2]]]
44
+ for x in typing.Iter[typing.Attrs[T]]
45
+ if not typing.IsAssignable[x.type, Column[Any, Any, PrimaryKey[Any]]]
46
+ ]
47
+ ]
48
+
49
+
50
+ # A column's value as it comes back from a query: PrimaryKey[int] -> int,
51
+ # anything else unchanged.
52
+ type Unwrap[T] = (
53
+ typing.GetArg[T, PrimaryKey, Literal[0]] if typing.IsAssignable[T, PrimaryKey[Any]] else T
54
+ )
55
+
56
+
57
+ # A table type -> the TypedDict of a full result row: every column, PK unwrapped.
58
+ type ResultRow[T] = typing.NewTypedDict[
59
+ *[
60
+ typing.Member[x.name, Unwrap[typing.GetArg[x.type, Column, Literal[2]]]]
61
+ for x in typing.Iter[typing.Attrs[T]]
62
+ ]
63
+ ]
64
+
65
+
66
+ # --- projection -------------------------------------------------------------
67
+ #
68
+ # A projected item is a bare column, an As[expr, Literal[alias]] rename, or an
69
+ # aggregate. These three helpers each take one item and are applied to the loop
70
+ # variable of ProjectedRow's comprehension (helper aliases on a loop variable
71
+ # evaluate on both tracks; nested comprehensions do not).
72
+
73
+ # The output key: the alias if the item is wrapped in As, else the column name.
74
+ type ItemName[C] = (
75
+ typing.GetArg[C, As, Literal[1]]
76
+ if typing.IsAssignable[C, As[Any, Any]]
77
+ else typing.GetArg[C, Column, Literal[1]]
78
+ )
79
+
80
+
81
+ # The underlying expression: unwrap one layer of As.
82
+ type ItemExpr[C] = typing.GetArg[C, As, Literal[0]] if typing.IsAssignable[C, As[Any, Any]] else C
83
+
84
+
85
+ # The value type of an expression: Count[...] is int; a column is its own type
86
+ # with the primary key unwrapped.
87
+ type ItemType[E] = (
88
+ int if typing.IsAssignable[E, Count[Any]] else Unwrap[typing.GetArg[E, Column, Literal[2]]]
89
+ )
90
+
91
+
92
+ # The underlying column of an item, unwrapping As then Count, and its owner table
93
+ # (a Column's first argument). Used to check the column belongs to the FROM.
94
+ type ColExpr[C] = (
95
+ typing.GetArg[ItemExpr[C], Count, Literal[0]]
96
+ if typing.IsAssignable[ItemExpr[C], Count[Any]]
97
+ else ItemExpr[C]
98
+ )
99
+ type OwnerOf[C] = typing.GetArg[ColExpr[C], Column, Literal[0]]
100
+
101
+
102
+ # The two sides of a join source (only reached when Src is an InnerJoin).
103
+ type JoinLeft[Src] = typing.GetArg[Src, InnerJoin, Literal[0]]
104
+ type JoinRight[Src] = typing.GetArg[Src, InnerJoin, Literal[1]]
105
+
106
+
107
+ # A Cols[...] projection over source Src -> the TypedDict of exactly the projected
108
+ # items, in order. Each item's column must belong to a table in the FROM (Src is a
109
+ # table, or an InnerJoin's two sides), else a type error.
110
+ #
111
+ # The scope check rides in the NAME slot: NewTypedDict forces every key, whereas
112
+ # the value slot stays lazy on the static track (a RaiseError there never fires).
113
+ # The membership boolean is written inline -- a helper alias in the if-slot would
114
+ # be a truthy TypeAliasType, not a real bool (see [[ternary-laziness-is-load-bearing]]).
115
+ # `and` short-circuits on both tracks, so the InnerJoin GetArgs are reached only
116
+ # when Src actually is a join.
117
+ type ProjectedRow[Src, P] = typing.NewTypedDict[
118
+ *[
119
+ typing.Member[
120
+ typing.RaiseError[Literal["Col: table is not in the FROM clause"]]
121
+ if not (
122
+ (
123
+ typing.IsAssignable[Src, InnerJoin[Any, Any, *tuple[Any, ...]]]
124
+ and (
125
+ typing.IsAssignable[OwnerOf[c], JoinLeft[Src]]
126
+ or typing.IsAssignable[OwnerOf[c], JoinRight[Src]]
127
+ )
128
+ )
129
+ or (
130
+ not typing.IsAssignable[Src, InnerJoin[Any, Any, *tuple[Any, ...]]]
131
+ and typing.IsAssignable[OwnerOf[c], Src]
132
+ )
133
+ )
134
+ else ItemName[c],
135
+ ItemType[ItemExpr[c]],
136
+ ]
137
+ for c in typing.Iter[typing.GetArgs[P, Cols]]
138
+ ]
139
+ ]
140
+
141
+
142
+ # --- WHERE parameters -------------------------------------------------------
143
+
144
+ # A Where[...] clause -> the TypedDict of its parameters. Each predicate is an
145
+ # Eq whose right-hand side is a Param[Literal[name], type]; a single comprehension
146
+ # collects them (one conjunctive level, which is all the fork can flatten).
147
+ type FlatParams[W] = typing.NewTypedDict[
148
+ *[
149
+ typing.Member[
150
+ typing.GetArg[typing.GetArg[pred, Eq, Literal[1]], Param, Literal[0]],
151
+ typing.GetArg[typing.GetArg[pred, Eq, Literal[1]], Param, Literal[1]],
152
+ ]
153
+ for pred in typing.Iter[typing.GetArgs[W, Where]]
154
+ ]
155
+ ]
156
+
157
+
158
+ # --- SELECT dispatch --------------------------------------------------------
159
+
160
+ # Result rows of a SELECT. With a lone source argument it is SELECT * (a full
161
+ # ResultRow); otherwise the second argument is the Cols projection. Star vs
162
+ # projection is decided by Length, not IsAssignable: the runtime evaluator
163
+ # unifies variadic tails permissively, so IsAssignable alone cannot tell
164
+ # Select[T] apart from Select[T, Cols[...]].
165
+ type _SelectResult[S] = (
166
+ list[ResultRow[typing.GetArg[S, Select, Literal[0]]]]
167
+ if typing.IsEquivalent[typing.Length[typing.GetArgs[S, Select]], Literal[1]]
168
+ else list[
169
+ ProjectedRow[typing.GetArg[S, Select, Literal[0]], typing.GetArg[S, Select, Literal[1]]]
170
+ ]
171
+ )
172
+
173
+
174
+ # Parameters of a SELECT. A Where clause, when present, is the modifier right
175
+ # after Cols (index 2). The Length guards keep the out-of-range index-2 lookup
176
+ # from ever being evaluated when there is no such modifier (`and` short-circuits
177
+ # on both tracks).
178
+ type _SelectParams[S] = (
179
+ FlatParams[typing.GetArg[S, Select, Literal[2]]]
180
+ if (
181
+ not typing.IsEquivalent[typing.Length[typing.GetArgs[S, Select]], Literal[1]]
182
+ and not typing.IsEquivalent[typing.Length[typing.GetArgs[S, Select]], Literal[2]]
183
+ and typing.IsAssignable[typing.GetArg[S, Select, Literal[2]], Where[*tuple[Any, ...]]]
184
+ )
185
+ else None
186
+ )
187
+
188
+
189
+ # A carrier that pairs a statement's params with its result, so both facts are
190
+ # written together on one branch instead of being spread across two ladders.
191
+ class Spec[Params, Result]:
192
+ pass
193
+
194
+
195
+ # fmt: off
196
+ type SpecOf[S: Statement] = (
197
+ Spec[Row[typing.GetArg[S, Insert, Literal[0]]], int]
198
+ if typing.IsAssignable[S, Insert[Any, *tuple[Any, ...]]]
199
+
200
+ else Spec[_SelectParams[S], _SelectResult[S]]
201
+ if typing.IsAssignable[S, Select[Any, *tuple[Any, ...]]]
202
+
203
+ else Spec[None, None]
204
+ if typing.IsAssignable[S, CreateTable[Any]]
205
+
206
+ else typing.RaiseError[Literal["run: unsupported statement"]]
207
+ )
208
+ # fmt: on
209
+
210
+
211
+ # ParamsOf and ResultOf are the two symmetric projections of that one ladder.
212
+ type ParamsOf[S: Statement] = typing.GetArg[SpecOf[S], Spec, Literal[0]]
213
+ type ResultOf[S: Statement] = typing.GetArg[SpecOf[S], Spec, Literal[1]]
214
+
215
+
216
+ def run[S: Statement](stmt: type[S], data: ParamsOf[S]) -> ResultOf[S]:
217
+ """The typed contract of a statement: ``data`` in, rows out.
218
+
219
+ There is no database bridge yet, so this raises at runtime; its *signature*
220
+ is the product — ``ParamsOf[S]`` and ``ResultOf[S]`` are what a type checker
221
+ enforces at every call site. Use ``tysql.render.render`` for the SQL text.
222
+ """
223
+ raise NotImplementedError
@@ -0,0 +1,100 @@
1
+ """The vocabulary of SQL statements, expressed as types.
2
+
3
+ Every SQL statement is written as a *type*, not a value: ``Select[User]`` is a
4
+ type whose structure a type checker can inspect. ``tysql.shapes`` turns that
5
+ structure into the parameters a statement takes and the rows it returns;
6
+ ``tysql.render`` turns it into PostgreSQL text.
7
+
8
+ The combinators below are deliberately close to SQL keywords so a query reads
9
+ the way the SQL does:
10
+
11
+ Select[InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]],
12
+ Col[Post, Literal["author"]]]]],
13
+ Cols[Col[User, Literal["email"]], Col[Post, Literal["text"]]]]
14
+ """
15
+
16
+
17
+ class Statement:
18
+ """Base type for a single SQL statement expressed as a value."""
19
+
20
+
21
+ class CreateTable[T](Statement):
22
+ """``CREATE TABLE`` for the table type ``T``."""
23
+
24
+
25
+ class Insert[T, *Mods](Statement):
26
+ """``INSERT INTO`` the table type ``T``."""
27
+
28
+
29
+ class Select[Src, *Mods](Statement):
30
+ """``SELECT`` from ``Src`` (a table or a join), refined by ``*Mods``.
31
+
32
+ The modifiers appear in SQL order: an optional ``Cols`` projection, then an
33
+ optional ``Where``, ``GroupBy`` and ``OrderBy``. With no modifiers at all,
34
+ ``Select[User]`` means ``SELECT *``.
35
+ """
36
+
37
+
38
+ # --- FROM sources -----------------------------------------------------------
39
+
40
+
41
+ class InnerJoin[Left, Right, *Mods]:
42
+ """An ``INNER JOIN`` between two tables, usable as ``Select``'s source.
43
+
44
+ The join predicate is carried as an ``On`` modifier, e.g.
45
+ ``InnerJoin[User, Post, On[Eq[Col[User, Literal["id"]],
46
+ Col[Post, Literal["author"]]]]]``.
47
+ """
48
+
49
+
50
+ class On[Pred]:
51
+ """The ``ON`` predicate of a join."""
52
+
53
+
54
+ # --- projection -------------------------------------------------------------
55
+
56
+
57
+ class Cols[*Cs]:
58
+ """A ``SELECT`` projection: the ordered list of items to return.
59
+
60
+ Each item is a bare ``Col``, an ``As[item, Literal[alias]]`` rename, or an
61
+ aggregate such as ``Count``.
62
+ """
63
+
64
+
65
+ class As[C, N]:
66
+ """Alias a projected item: the output key becomes ``N`` instead of the column name."""
67
+
68
+
69
+ class Count[C]:
70
+ """The ``count(...)`` aggregate over column ``C``; its result type is ``int``."""
71
+
72
+
73
+ # --- filtering --------------------------------------------------------------
74
+
75
+
76
+ class Where[*Preds]:
77
+ """A ``WHERE`` clause: a conjunction (``AND``) of ``Eq`` predicates."""
78
+
79
+
80
+ class Eq[L, R]:
81
+ """Equality between a column and another column or a ``Param``."""
82
+
83
+
84
+ class Param[Name, T]:
85
+ """A bound parameter named ``Name`` carrying value type ``T``.
86
+
87
+ Every ``Param`` in a ``Where`` clause is collected into the parameter
88
+ mapping a statement expects (see ``tysql.shapes.ParamsOf``).
89
+ """
90
+
91
+
92
+ # --- grouping / ordering ----------------------------------------------------
93
+
94
+
95
+ class GroupBy[*Cs]:
96
+ """A ``GROUP BY`` over one or more columns."""
97
+
98
+
99
+ class OrderBy[C, Dir]:
100
+ """An ``ORDER BY`` on column ``C`` with direction ``Dir`` (``"asc"``/``"desc"``)."""