hayate-sql 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.
hayate_sql/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """hayate-sql: SQL-first query contracts, not an ORM."""
2
+
3
+ from .contract import (
4
+ Cardinality,
5
+ CardinalityError,
6
+ Column,
7
+ Parameter,
8
+ Query,
9
+ QueryArgumentError,
10
+ QueryContractError,
11
+ RowShapeError,
12
+ )
13
+ from .database import CommandResult, Database, Observer, QueryEvent, Row
14
+ from .parser import QueryParseError, load_queries, parse_query
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = [
19
+ "Cardinality",
20
+ "CardinalityError",
21
+ "Column",
22
+ "CommandResult",
23
+ "Database",
24
+ "Observer",
25
+ "Parameter",
26
+ "Query",
27
+ "QueryArgumentError",
28
+ "QueryContractError",
29
+ "QueryEvent",
30
+ "QueryParseError",
31
+ "Row",
32
+ "RowShapeError",
33
+ "__version__",
34
+ "load_queries",
35
+ "parse_query",
36
+ ]
hayate_sql/__main__.py ADDED
@@ -0,0 +1,105 @@
1
+ """The ``hayate-sql`` command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import NoReturn
11
+
12
+ from .check import CheckFailure, check_postgres, check_sqlite
13
+ from .generate import generate_module
14
+ from .migrations import load_migrations
15
+ from .parser import load_queries
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ parser = _parser()
20
+ args = parser.parse_args(argv)
21
+ try:
22
+ queries = load_queries([Path(item) for item in args.paths])
23
+ if args.command == "generate":
24
+ output = generate_module([query for _, query in queries])
25
+ Path(args.output).write_text(output)
26
+ print(f"generated {len(queries)} queries in {args.output}")
27
+ return 0
28
+
29
+ schemas = [Path(path).read_text() for path in args.schema]
30
+ migrations = load_migrations(Path(args.migrations)) if args.migrations else ()
31
+ if args.dialect in ("sqlite", "d1"):
32
+ failures = check_sqlite(
33
+ queries,
34
+ schemas=schemas,
35
+ migrations=migrations,
36
+ d1=args.dialect == "d1",
37
+ )
38
+ else:
39
+ database_url = args.database_url or os.environ.get("HAYATE_SQL_DATABASE_URL")
40
+ if database_url is None:
41
+ parser.error("PostgreSQL checks require --database-url or HAYATE_SQL_DATABASE_URL")
42
+ failures = asyncio.run(
43
+ check_postgres(
44
+ queries,
45
+ database_url=database_url,
46
+ schemas=schemas,
47
+ migrations=migrations,
48
+ )
49
+ )
50
+ return _report(failures, len(queries))
51
+ except (OSError, RuntimeError, ValueError) as error:
52
+ print(f"hayate-sql: {error}", file=sys.stderr)
53
+ return 1
54
+
55
+
56
+ def entrypoint() -> NoReturn:
57
+ raise SystemExit(main())
58
+
59
+
60
+ def _parser() -> argparse.ArgumentParser:
61
+ parser = argparse.ArgumentParser(
62
+ prog="hayate-sql",
63
+ description="Compile SQL contracts and generate typed Python accessors.",
64
+ )
65
+ commands = parser.add_subparsers(dest="command", required=True)
66
+
67
+ generate = commands.add_parser("generate", help="generate a typed Python module")
68
+ generate.add_argument("paths", nargs="+", help=".sql files or directories")
69
+ generate.add_argument("-o", "--output", required=True, help="generated .py path")
70
+
71
+ check = commands.add_parser("check", help="compile queries against a real schema")
72
+ check.add_argument("paths", nargs="+", help=".sql files or directories")
73
+ check.add_argument("--dialect", choices=("sqlite", "d1", "postgres"), default="sqlite")
74
+ schema_source = check.add_mutually_exclusive_group()
75
+ schema_source.add_argument(
76
+ "--schema",
77
+ action="append",
78
+ default=[],
79
+ metavar="FILE",
80
+ help="schema SQL to apply before checking; repeatable",
81
+ )
82
+ schema_source.add_argument(
83
+ "--migrations",
84
+ metavar="DIRECTORY",
85
+ help="ordered SQL migrations to replay in a disposable check database",
86
+ )
87
+ check.add_argument(
88
+ "--database-url",
89
+ help="ephemeral PostgreSQL URL; prefer HAYATE_SQL_DATABASE_URL to avoid shell history",
90
+ )
91
+ return parser
92
+
93
+
94
+ def _report(failures: list[CheckFailure], total: int) -> int:
95
+ if not failures:
96
+ print(f"checked {total} queries")
97
+ return 0
98
+ for failure in failures:
99
+ print(f"{failure.path}: {failure.query_name}: {failure.message}", file=sys.stderr)
100
+ print(f"{len(failures)} of {total} queries failed", file=sys.stderr)
101
+ return 1
102
+
103
+
104
+ if __name__ == "__main__":
105
+ entrypoint()
@@ -0,0 +1,7 @@
1
+ """Database-native hayate-sql adapters."""
2
+
3
+ from .d1 import D1Database
4
+ from .postgres import AsyncpgDatabase
5
+ from .sqlite import SQLiteDatabase
6
+
7
+ __all__ = ["AsyncpgDatabase", "D1Database", "SQLiteDatabase"]
@@ -0,0 +1,72 @@
1
+ """Cloudflare D1 adapter using the binding's native prepare/bind API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ from ..database import BaseDatabase, CommandResult, Observer, Row
9
+
10
+
11
+ class D1Database(BaseDatabase):
12
+ """Wrap a D1Database or D1DatabaseSession JS binding.
13
+
14
+ Transactions are intentionally absent: D1's Sessions/bookmarks and batch
15
+ behavior are not equivalent to a PostgreSQL or SQLite transaction.
16
+ """
17
+
18
+ def __init__(self, database: Any, *, observer: Observer | None = None) -> None:
19
+ super().__init__(observer=observer)
20
+ self.raw = database
21
+
22
+ def with_session(self, constraint_or_bookmark: str | None = None) -> D1Database:
23
+ session = (
24
+ self.raw.withSession()
25
+ if constraint_or_bookmark is None
26
+ else self.raw.withSession(constraint_or_bookmark)
27
+ )
28
+ return D1Database(session, observer=self.observer)
29
+
30
+ def bookmark(self) -> str | None:
31
+ getter = getattr(self.raw, "getBookmark", None)
32
+ if getter is None:
33
+ raise RuntimeError("bookmark() requires a D1DatabaseSession")
34
+ value = getter()
35
+ return None if value is None else str(value)
36
+
37
+ async def _fetch(self, sql: str, values: Sequence[Any]) -> list[Row]:
38
+ statement = self._statement(sql, values)
39
+ result = await statement.all()
40
+ raw_rows = _get(result, "results")
41
+ if raw_rows is None:
42
+ return []
43
+ converted = _to_py(raw_rows)
44
+ return [dict(_to_py(row)) for row in converted]
45
+
46
+ async def _execute(self, sql: str, values: Sequence[Any]) -> CommandResult:
47
+ statement = self._statement(sql, values)
48
+ runner = getattr(statement, "run", None)
49
+ result = await (runner() if runner is not None else statement.all())
50
+ meta = _to_py(_get(result, "meta"))
51
+ changes = _get(meta, "changes")
52
+ return CommandResult(rows_affected=int(changes) if changes is not None else None)
53
+
54
+ def _statement(self, sql: str, values: Sequence[Any]) -> Any:
55
+ statement = self.raw.prepare(sql)
56
+ return statement.bind(*values) if values else statement
57
+
58
+
59
+ def _to_py(value: Any) -> Any:
60
+ if value is None:
61
+ return None
62
+ converter = getattr(value, "to_py", None)
63
+ return converter() if converter is not None else value
64
+
65
+
66
+ def _get(value: Any, name: str) -> Any:
67
+ if value is None:
68
+ return None
69
+ converted = _to_py(value)
70
+ if isinstance(converted, dict):
71
+ return converted.get(name)
72
+ return getattr(converted, name, None)
@@ -0,0 +1,36 @@
1
+ """PostgreSQL adapter for asyncpg Connection/Pool-compatible objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ from ..database import BaseDatabase, CommandResult, Observer, Row
10
+
11
+ _AFFECTED_ROWS = re.compile(r"(?:^|\s)(\d+)$")
12
+
13
+
14
+ class AsyncpgDatabase(BaseDatabase):
15
+ """Keep PostgreSQL SQL and asyncpg's native transaction model intact."""
16
+
17
+ def __init__(self, connection_or_pool: Any, *, observer: Observer | None = None) -> None:
18
+ super().__init__(observer=observer)
19
+ self.raw = connection_or_pool
20
+
21
+ def transaction(self, **options: Any) -> Any:
22
+ """Delegate to an asyncpg Connection's native transaction context."""
23
+
24
+ transaction = getattr(self.raw, "transaction", None)
25
+ if transaction is None:
26
+ raise RuntimeError("transaction() requires an asyncpg Connection, not a Pool")
27
+ return transaction(**options)
28
+
29
+ async def _fetch(self, sql: str, values: Sequence[Any]) -> list[Row]:
30
+ rows = await self.raw.fetch(sql, *values)
31
+ return [dict(row) for row in rows]
32
+
33
+ async def _execute(self, sql: str, values: Sequence[Any]) -> CommandResult:
34
+ status = str(await self.raw.execute(sql, *values))
35
+ matched = _AFFECTED_ROWS.search(status)
36
+ return CommandResult(rows_affected=int(matched.group(1)) if matched else None)
@@ -0,0 +1,104 @@
1
+ """stdlib SQLite adapter with explicit SQLite transaction semantics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sqlite3
7
+ import sys
8
+ from collections.abc import AsyncIterator, Callable, Sequence
9
+ from contextlib import asynccontextmanager
10
+ from pathlib import Path
11
+ from typing import Any, Literal
12
+
13
+ from ..database import BaseDatabase, CommandResult, Observer, Row
14
+
15
+
16
+ class SQLiteDatabase(BaseDatabase):
17
+ def __init__(
18
+ self,
19
+ path: str | Path = ":memory:",
20
+ *,
21
+ observer: Observer | None = None,
22
+ ) -> None:
23
+ super().__init__(observer=observer)
24
+ self._connection = sqlite3.connect(
25
+ str(path),
26
+ check_same_thread=False,
27
+ isolation_level=None,
28
+ )
29
+ self._connection.row_factory = sqlite3.Row
30
+ self._connection.execute("PRAGMA foreign_keys = ON")
31
+ self._lock = asyncio.Lock()
32
+ self._transaction_owner: asyncio.Task[Any] | None = None
33
+
34
+ @property
35
+ def raw(self) -> sqlite3.Connection:
36
+ return self._connection
37
+
38
+ async def executescript(self, sql: str) -> None:
39
+ await self._serialized(lambda: self._connection.executescript(sql))
40
+
41
+ async def close(self) -> None:
42
+ await self._serialized(self._connection.close)
43
+
44
+ async def __aenter__(self) -> SQLiteDatabase:
45
+ return self
46
+
47
+ async def __aexit__(self, *exc_info: object) -> None:
48
+ await self.close()
49
+
50
+ @asynccontextmanager
51
+ async def transaction(
52
+ self,
53
+ *,
54
+ mode: Literal["deferred", "immediate", "exclusive"] = "deferred",
55
+ ) -> AsyncIterator[SQLiteDatabase]:
56
+ """Run calls through this adapter in one native SQLite transaction."""
57
+
58
+ if mode not in ("deferred", "immediate", "exclusive"):
59
+ raise ValueError("mode must be 'deferred', 'immediate', or 'exclusive'")
60
+ task = asyncio.current_task()
61
+ if task is None:
62
+ raise RuntimeError("SQLite transactions require an asyncio task")
63
+ if self._transaction_owner is task:
64
+ raise RuntimeError("nested SQLiteDatabase transactions are not supported")
65
+
66
+ await self._lock.acquire()
67
+ self._transaction_owner = task
68
+ try:
69
+ await self._call(lambda: self._connection.execute(f"BEGIN {mode.upper()}"))
70
+ try:
71
+ yield self
72
+ except BaseException:
73
+ await self._call(self._connection.rollback)
74
+ raise
75
+ else:
76
+ await self._call(self._connection.commit)
77
+ finally:
78
+ self._transaction_owner = None
79
+ self._lock.release()
80
+
81
+ async def _fetch(self, sql: str, values: Sequence[Any]) -> list[Row]:
82
+ def run() -> list[Row]:
83
+ cursor = self._connection.execute(sql, tuple(values))
84
+ return [dict(row) for row in cursor.fetchall()]
85
+
86
+ return await self._serialized(run)
87
+
88
+ async def _execute(self, sql: str, values: Sequence[Any]) -> CommandResult:
89
+ def run() -> CommandResult:
90
+ cursor = self._connection.execute(sql, tuple(values))
91
+ return CommandResult(rows_affected=max(cursor.rowcount, 0))
92
+
93
+ return await self._serialized(run)
94
+
95
+ async def _serialized[T](self, operation: Callable[[], T]) -> T:
96
+ if self._transaction_owner is asyncio.current_task():
97
+ return await self._call(operation)
98
+ async with self._lock:
99
+ return await self._call(operation)
100
+
101
+ async def _call[T](self, operation: Callable[[], T]) -> T:
102
+ if sys.platform == "emscripten":
103
+ return operation()
104
+ return await asyncio.to_thread(operation)
hayate_sql/check.py ADDED
@@ -0,0 +1,149 @@
1
+ """Database-backed compilation of SQL contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from importlib import import_module
9
+ from pathlib import Path
10
+
11
+ from .contract import Cardinality, Query
12
+ from .migrations import Migration
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class CheckFailure:
17
+ path: Path
18
+ query_name: str
19
+ message: str
20
+
21
+
22
+ def check_sqlite(
23
+ queries: list[tuple[Path, Query]],
24
+ *,
25
+ schemas: Sequence[str] = (),
26
+ migrations: Sequence[Migration] = (),
27
+ d1: bool = False,
28
+ ) -> list[CheckFailure]:
29
+ """Compile queries against an in-memory SQLite/D1 schema without executing them."""
30
+
31
+ connection = sqlite3.connect(":memory:")
32
+ failures: list[CheckFailure] = []
33
+ try:
34
+ connection.execute("PRAGMA foreign_keys = ON")
35
+ for schema in schemas:
36
+ connection.executescript(schema)
37
+ for migration in migrations:
38
+ try:
39
+ connection.executescript(migration.sql)
40
+ except sqlite3.Error as error:
41
+ failures.append(
42
+ CheckFailure(
43
+ migration.path,
44
+ f"migration {migration.version_text}",
45
+ str(error),
46
+ )
47
+ )
48
+ return failures
49
+ for path, query in queries:
50
+ if d1 and len(query.parameters) > 100:
51
+ failures.append(
52
+ CheckFailure(
53
+ path,
54
+ query.name,
55
+ f"D1 accepts at most 100 bound parameters, got {len(query.parameters)}",
56
+ )
57
+ )
58
+ continue
59
+ values = (None,) * len(query.parameters)
60
+ try:
61
+ connection.execute(f"EXPLAIN {query.sql}", values)
62
+ if query.cardinality is not Cardinality.EXEC:
63
+ _check_sqlite_columns(connection, query, values)
64
+ except (sqlite3.Error, ValueError) as error:
65
+ failures.append(CheckFailure(path, query.name, str(error)))
66
+ finally:
67
+ connection.close()
68
+ return failures
69
+
70
+
71
+ async def check_postgres(
72
+ queries: list[tuple[Path, Query]],
73
+ *,
74
+ database_url: str,
75
+ schemas: Sequence[str] = (),
76
+ migrations: Sequence[Migration] = (),
77
+ ) -> list[CheckFailure]:
78
+ """Ask PostgreSQL to parse and describe every query, then roll all DDL back."""
79
+
80
+ try:
81
+ asyncpg = import_module("asyncpg")
82
+ except ImportError as error:
83
+ raise RuntimeError(
84
+ "PostgreSQL checks require the 'postgres' extra: uv add 'hayate-sql[postgres]'"
85
+ ) from error
86
+
87
+ connection = await asyncpg.connect(database_url)
88
+ failures: list[CheckFailure] = []
89
+ transaction = connection.transaction()
90
+ await transaction.start()
91
+ try:
92
+ for schema in schemas:
93
+ await connection.execute(schema)
94
+ for migration in migrations:
95
+ try:
96
+ await connection.execute(migration.sql)
97
+ except Exception as error:
98
+ failures.append(
99
+ CheckFailure(
100
+ migration.path,
101
+ f"migration {migration.version_text}",
102
+ str(error),
103
+ )
104
+ )
105
+ return failures
106
+ for path, query in queries:
107
+ savepoint = connection.transaction()
108
+ await savepoint.start()
109
+ try:
110
+ prepared = await connection.prepare(query.sql)
111
+ if query.cardinality is not Cardinality.EXEC:
112
+ actual = tuple(attribute.name for attribute in prepared.get_attributes())
113
+ _ensure_columns(query, actual)
114
+ except Exception as error:
115
+ failures.append(CheckFailure(path, query.name, str(error)))
116
+ finally:
117
+ await savepoint.rollback()
118
+ finally:
119
+ await transaction.rollback()
120
+ await connection.close()
121
+ return failures
122
+
123
+
124
+ def _check_sqlite_columns(
125
+ connection: sqlite3.Connection,
126
+ query: Query,
127
+ values: tuple[None, ...],
128
+ ) -> None:
129
+ sql = query.sql.rstrip().removesuffix(";")
130
+ try:
131
+ cursor = connection.execute(
132
+ f"SELECT * FROM ({sql}) AS _hayate_sql_contract LIMIT 0",
133
+ values,
134
+ )
135
+ except sqlite3.Error:
136
+ # SQLite cannot wrap every valid row-returning statement (for example,
137
+ # INSERT ... RETURNING). EXPLAIN above still validates those statements;
138
+ # their row shape remains protected at runtime.
139
+ return
140
+ actual = tuple(item[0] for item in cursor.description or ())
141
+ _ensure_columns(query, actual)
142
+
143
+
144
+ def _ensure_columns(query: Query, actual: tuple[str, ...]) -> None:
145
+ expected = tuple(column.name for column in query.columns)
146
+ if actual != expected:
147
+ raise ValueError(
148
+ f"declared columns {expected!r} do not match database result columns {actual!r}"
149
+ )
hayate_sql/contract.py ADDED
@@ -0,0 +1,111 @@
1
+ """The immutable query contract.
2
+
3
+ SQL remains native to its database. This module only describes the boundary
4
+ around it: parameter order, expected result shape, cardinality, and deadline.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from enum import StrEnum
14
+ from typing import Any
15
+
16
+
17
+ class Cardinality(StrEnum):
18
+ ONE = "one"
19
+ MAYBE_ONE = "one?"
20
+ MANY = "many"
21
+ EXEC = "exec"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Parameter:
26
+ name: str
27
+ annotation: str
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Column:
32
+ name: str
33
+ annotation: str
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Query:
38
+ """A parsed, database-native SQL statement and its execution contract."""
39
+
40
+ name: str
41
+ sql: str
42
+ cardinality: Cardinality
43
+ parameters: tuple[Parameter, ...] = ()
44
+ columns: tuple[Column, ...] = ()
45
+ timeout_ms: int | None = None
46
+
47
+ def __post_init__(self) -> None:
48
+ if not self.name:
49
+ raise ValueError("query name cannot be empty")
50
+ if not self.sql.strip():
51
+ raise ValueError(f"{self.name}: SQL cannot be empty")
52
+ if self.timeout_ms is not None and self.timeout_ms <= 0:
53
+ raise ValueError(f"{self.name}: timeout must be positive")
54
+
55
+ parameter_names = [parameter.name for parameter in self.parameters]
56
+ column_names = [column.name for column in self.columns]
57
+ if len(parameter_names) != len(set(parameter_names)):
58
+ raise ValueError(f"{self.name}: duplicate parameter name")
59
+ if len(column_names) != len(set(column_names)):
60
+ raise ValueError(f"{self.name}: duplicate column name")
61
+ if self.cardinality is Cardinality.EXEC and self.columns:
62
+ raise ValueError(f"{self.name}: :exec queries cannot declare columns")
63
+ if self.cardinality is not Cardinality.EXEC and not self.columns:
64
+ raise ValueError(f"{self.name}: row-returning queries must declare columns")
65
+
66
+ @property
67
+ def fingerprint(self) -> str:
68
+ """Stable identifier for telemetry; contains neither argument values nor secrets."""
69
+
70
+ contract = {
71
+ "name": self.name,
72
+ "sql": self.sql,
73
+ "cardinality": self.cardinality,
74
+ "parameters": [(item.name, item.annotation) for item in self.parameters],
75
+ "columns": [(item.name, item.annotation) for item in self.columns],
76
+ "timeout_ms": self.timeout_ms,
77
+ }
78
+ encoded = json.dumps(contract, separators=(",", ":"), sort_keys=True).encode()
79
+ return hashlib.sha256(encoded).hexdigest()[:16]
80
+
81
+ def bind(self, values: Mapping[str, Any]) -> tuple[Any, ...]:
82
+ """Turn named application arguments into the SQL file's positional order."""
83
+
84
+ expected = {parameter.name for parameter in self.parameters}
85
+ actual = set(values)
86
+ missing = expected - actual
87
+ extra = actual - expected
88
+ if missing or extra:
89
+ details: list[str] = []
90
+ if missing:
91
+ details.append(f"missing {', '.join(sorted(missing))}")
92
+ if extra:
93
+ details.append(f"unexpected {', '.join(sorted(extra))}")
94
+ raise QueryArgumentError(f"{self.name}: {'; '.join(details)}")
95
+ return tuple(values[parameter.name] for parameter in self.parameters)
96
+
97
+
98
+ class QueryContractError(RuntimeError):
99
+ """Base class for failures at the SQL contract boundary."""
100
+
101
+
102
+ class QueryArgumentError(QueryContractError):
103
+ """Arguments do not match the declared query parameters."""
104
+
105
+
106
+ class CardinalityError(QueryContractError):
107
+ """The database returned a different number of rows than promised."""
108
+
109
+
110
+ class RowShapeError(QueryContractError):
111
+ """The database returned columns that differ from the declared row."""