tysql 0.1.0__py3-none-any.whl
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/__init__.py +51 -0
- tysql/__main__.py +4 -0
- tysql/cli.py +101 -0
- tysql/py.typed +1 -0
- tysql/render.py +202 -0
- tysql/schema.py +64 -0
- tysql/shapes.py +223 -0
- tysql/statements.py +100 -0
- tysql-0.1.0.dist-info/METADATA +203 -0
- tysql-0.1.0.dist-info/RECORD +12 -0
- tysql-0.1.0.dist-info/WHEEL +4 -0
- tysql-0.1.0.dist-info/entry_points.txt +3 -0
tysql/__init__.py
ADDED
|
@@ -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
|
+
]
|
tysql/__main__.py
ADDED
tysql/cli.py
ADDED
|
@@ -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())
|
tysql/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
tysql/render.py
ADDED
|
@@ -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 + ";"
|
tysql/schema.py
ADDED
|
@@ -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
|
+
"""
|
tysql/shapes.py
ADDED
|
@@ -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
|
tysql/statements.py
ADDED
|
@@ -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"``)."""
|
|
@@ -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.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
tysql/__init__.py,sha256=FiimWWs1bGqi1pUfOCMGPpdVkVZtexXd-7FtnDEAiZ0,903
|
|
2
|
+
tysql/__main__.py,sha256=TrqXFR26fOg0EcmhqgYp8O7_DpVLrGIqLx9ux23rSes,84
|
|
3
|
+
tysql/cli.py,sha256=GRbGIiqUeUtv29ELpH-ggbPnPG8q5xJMF-gLGVn3m8M,3371
|
|
4
|
+
tysql/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
5
|
+
tysql/render.py,sha256=BN-99jnoEJidEaXu7Ws3WzvDud8rII9QrHJ-78FG_dU,6241
|
|
6
|
+
tysql/schema.py,sha256=T4pHqnbIjHyf9X1TQficuM3Df_qDhtPMln6X4ThYzXk,2378
|
|
7
|
+
tysql/shapes.py,sha256=FTHinntadyC8atxyxfORmv2vxfuFN0E-SRshZ2xauR0,8296
|
|
8
|
+
tysql/statements.py,sha256=Pad1raI6RM-D7riJl5SCTcvH9KS68-4HdasC9fs0l8A,2970
|
|
9
|
+
tysql-0.1.0.dist-info/WHEEL,sha256=fWriCkzqm-pffF5af4gJC9iI5FMFaJTuN9UxxxzOmdY,81
|
|
10
|
+
tysql-0.1.0.dist-info/entry_points.txt,sha256=DCsPyq19cg3nFhmIC9JrAyPVYhZ4ZLsAuKhxB0MFXFM,42
|
|
11
|
+
tysql-0.1.0.dist-info/METADATA,sha256=DBPrAp9RVKC5xI4gJs-vBExhe8jNMJjmxevU1xiPGzk,8528
|
|
12
|
+
tysql-0.1.0.dist-info/RECORD,,
|