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/database.py ADDED
@@ -0,0 +1,135 @@
1
+ """Execution policy shared by the database-native adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import time
8
+ from abc import ABC, abstractmethod
9
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Any, Protocol
12
+
13
+ from .contract import Cardinality, CardinalityError, Query, RowShapeError
14
+
15
+ type Row = dict[str, Any]
16
+ type Observer = Callable[["QueryEvent"], Awaitable[None] | None]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class CommandResult:
21
+ rows_affected: int | None = None
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class QueryEvent:
26
+ """A parameter-free telemetry event safe to attach to logs and traces."""
27
+
28
+ name: str
29
+ fingerprint: str
30
+ duration_ms: float
31
+ succeeded: bool
32
+ row_count: int | None
33
+ error_type: str | None
34
+
35
+
36
+ class Database(Protocol):
37
+ async def run(self, query: Query, /, **params: Any) -> object: ...
38
+
39
+
40
+ class BaseDatabase(ABC):
41
+ """Cardinality, timeout, result-shape, and telemetry enforcement.
42
+
43
+ Adapters implement only native fetch/execute operations. This deliberately
44
+ does not define a universal transaction interface.
45
+ """
46
+
47
+ def __init__(self, *, observer: Observer | None = None) -> None:
48
+ self.observer = observer
49
+
50
+ async def run(self, query: Query, /, **params: Any) -> object:
51
+ values = query.bind(params)
52
+ started = time.perf_counter()
53
+ succeeded = False
54
+ row_count: int | None = None
55
+ error_type: str | None = None
56
+ try:
57
+ if query.timeout_ms is None:
58
+ result = await self._dispatch(query, values)
59
+ else:
60
+ async with asyncio.timeout(query.timeout_ms / 1000):
61
+ result = await self._dispatch(query, values)
62
+ succeeded = True
63
+ row_count = _row_count(result)
64
+ return result
65
+ except BaseException as error:
66
+ error_type = type(error).__name__
67
+ raise
68
+ finally:
69
+ event = QueryEvent(
70
+ name=query.name,
71
+ fingerprint=query.fingerprint,
72
+ duration_ms=(time.perf_counter() - started) * 1000,
73
+ succeeded=succeeded,
74
+ row_count=row_count,
75
+ error_type=error_type,
76
+ )
77
+ await self._observe(event)
78
+
79
+ async def _dispatch(self, query: Query, values: Sequence[Any]) -> object:
80
+ if query.cardinality is Cardinality.EXEC:
81
+ return await self._execute(query.sql, values)
82
+
83
+ rows = await self._fetch(query.sql, values)
84
+ _validate_rows(query, rows)
85
+ if query.cardinality is Cardinality.ONE:
86
+ if len(rows) != 1:
87
+ raise CardinalityError(f"{query.name}: expected exactly one row, got {len(rows)}")
88
+ return rows[0]
89
+ if query.cardinality is Cardinality.MAYBE_ONE:
90
+ if len(rows) > 1:
91
+ raise CardinalityError(f"{query.name}: expected at most one row, got {len(rows)}")
92
+ return rows[0] if rows else None
93
+ return rows
94
+
95
+ async def _observe(self, event: QueryEvent) -> None:
96
+ if self.observer is None:
97
+ return
98
+ try:
99
+ result = self.observer(event)
100
+ if inspect.isawaitable(result):
101
+ await result
102
+ except Exception:
103
+ # Instrumentation must never change query behavior.
104
+ return
105
+
106
+ @abstractmethod
107
+ async def _fetch(self, sql: str, values: Sequence[Any]) -> list[Row]: ...
108
+
109
+ @abstractmethod
110
+ async def _execute(self, sql: str, values: Sequence[Any]) -> CommandResult: ...
111
+
112
+
113
+ def _validate_rows(query: Query, rows: Sequence[Mapping[str, Any]]) -> None:
114
+ expected = {column.name for column in query.columns}
115
+ for index, row in enumerate(rows):
116
+ actual = set(row)
117
+ if actual != expected:
118
+ missing = sorted(expected - actual)
119
+ extra = sorted(actual - expected)
120
+ details: list[str] = []
121
+ if missing:
122
+ details.append(f"missing {', '.join(missing)}")
123
+ if extra:
124
+ details.append(f"unexpected {', '.join(extra)}")
125
+ raise RowShapeError(f"{query.name}: row {index} has {'; '.join(details)}")
126
+
127
+
128
+ def _row_count(result: object) -> int | None:
129
+ if isinstance(result, CommandResult):
130
+ return result.rows_affected
131
+ if isinstance(result, list):
132
+ return len(result)
133
+ if result is None:
134
+ return 0
135
+ return 1
hayate_sql/generate.py ADDED
@@ -0,0 +1,173 @@
1
+ """Generate a typed, dependency-free Python facade from SQL contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ from .contract import Cardinality, Query
9
+ from .parser import python_annotation
10
+
11
+
12
+ def generate_module(queries: list[Query]) -> str:
13
+ lines = [
14
+ '"""Generated by hayate-sql. Do not edit by hand."""',
15
+ "",
16
+ ]
17
+ lines.extend(_imports(queries))
18
+ lines.extend([""] * (2 if queries and queries[0].cardinality is not Cardinality.EXEC else 1))
19
+ exported: list[str] = []
20
+
21
+ for query in queries:
22
+ row_type = f"{_pascal_case(query.name)}Row"
23
+ constant = f"{query.name.upper()}_QUERY"
24
+ if query.cardinality is not Cardinality.EXEC:
25
+ lines.append(f"class {row_type}(TypedDict):")
26
+ for column in query.columns:
27
+ lines.append(f" {column.name}: {python_annotation(column.annotation)}")
28
+ lines.extend(["", ""])
29
+ exported.append(row_type)
30
+
31
+ lines.extend(_query_constant(query, constant))
32
+ lines.extend(["", ""])
33
+ lines.extend(_query_function(query, row_type, constant))
34
+ lines.extend(["", ""])
35
+ exported.extend([constant, query.name])
36
+
37
+ lines.append("__all__ = [")
38
+ for name in sorted(exported, key=_export_sort_key):
39
+ lines.append(f" {_literal(name)},")
40
+ lines.append("]")
41
+ lines.append("")
42
+ return "\n".join(lines)
43
+
44
+
45
+ def _query_constant(query: Query, constant: str) -> list[str]:
46
+ lines = [
47
+ f"{constant} = Query(",
48
+ f" name={_literal(query.name)},",
49
+ " sql=(",
50
+ ]
51
+ for sql_line in query.sql.splitlines(keepends=True):
52
+ lines.append(f" {_literal(sql_line)}")
53
+ lines.extend(
54
+ [
55
+ " ),",
56
+ f" cardinality=Cardinality.{_cardinality_member(query.cardinality)},",
57
+ ]
58
+ )
59
+ if query.parameters:
60
+ lines.append(" parameters=(")
61
+ for parameter in query.parameters:
62
+ lines.append(
63
+ f" Parameter({_literal(parameter.name)}, {_literal(parameter.annotation)}),"
64
+ )
65
+ lines.append(" ),")
66
+ else:
67
+ lines.append(" parameters=(),")
68
+ if query.columns:
69
+ lines.append(" columns=(")
70
+ for column in query.columns:
71
+ lines.append(f" Column({_literal(column.name)}, {_literal(column.annotation)}),")
72
+ lines.append(" ),")
73
+ else:
74
+ lines.append(" columns=(),")
75
+ lines.extend([" timeout_ms=" + repr(query.timeout_ms) + ",", ")"])
76
+ return lines
77
+
78
+
79
+ def _query_function(query: Query, row_type: str, constant: str) -> list[str]:
80
+ if query.cardinality is Cardinality.ONE:
81
+ return_type = row_type
82
+ elif query.cardinality is Cardinality.MAYBE_ONE:
83
+ return_type = f"{row_type} | None"
84
+ elif query.cardinality is Cardinality.MANY:
85
+ return_type = f"list[{row_type}]"
86
+ else:
87
+ return_type = "CommandResult"
88
+
89
+ if query.parameters:
90
+ signature = [f"async def {query.name}(", " db: Database,", " /,", " *,"]
91
+ for parameter in query.parameters:
92
+ annotation = python_annotation(parameter.annotation)
93
+ signature.append(f" {parameter.name}: {annotation},")
94
+ signature.append(f") -> {return_type}:")
95
+ call = [
96
+ " await db.run(",
97
+ f" {constant},",
98
+ *(f" {item.name}={item.name}," for item in query.parameters),
99
+ " ),",
100
+ ]
101
+ else:
102
+ signature = [f"async def {query.name}(db: Database, /) -> {return_type}:"]
103
+ call = [f" await db.run({constant}),"]
104
+ return [
105
+ *signature,
106
+ " return cast(",
107
+ f" {_literal(return_type)},",
108
+ *call,
109
+ " )",
110
+ ]
111
+
112
+
113
+ def _imports(queries: list[Query]) -> list[str]:
114
+ annotations = {
115
+ parameter.annotation.removesuffix("?")
116
+ for query in queries
117
+ for parameter in query.parameters
118
+ } | {column.annotation.removesuffix("?") for query in queries for column in query.columns}
119
+ lines: list[str] = []
120
+ datetime_types = sorted(annotations & {"date", "datetime"})
121
+ if datetime_types:
122
+ lines.append(f"from datetime import {', '.join(datetime_types)}")
123
+ if "Decimal" in annotations:
124
+ lines.append("from decimal import Decimal")
125
+ typing_types = ["cast"]
126
+ if "Any" in annotations:
127
+ typing_types.insert(0, "Any")
128
+ if any(query.cardinality is not Cardinality.EXEC for query in queries):
129
+ typing_types.insert(-1, "TypedDict")
130
+ lines.append(f"from typing import {', '.join(typing_types)}")
131
+ if "UUID" in annotations:
132
+ lines.append("from uuid import UUID")
133
+ lines.extend(
134
+ [
135
+ "",
136
+ "from hayate_sql import (",
137
+ " Cardinality,",
138
+ " Column,",
139
+ " CommandResult,",
140
+ " Database,",
141
+ " Parameter,",
142
+ " Query,",
143
+ ")",
144
+ ]
145
+ )
146
+ return lines
147
+
148
+
149
+ def _pascal_case(name: str) -> str:
150
+ return "".join(part.capitalize() for part in re.split(r"_+", name) if part)
151
+
152
+
153
+ def _cardinality_member(cardinality: Cardinality) -> str:
154
+ return {
155
+ Cardinality.ONE: "ONE",
156
+ Cardinality.MAYBE_ONE: "MAYBE_ONE",
157
+ Cardinality.MANY: "MANY",
158
+ Cardinality.EXEC: "EXEC",
159
+ }[cardinality]
160
+
161
+
162
+ def _literal(value: str) -> str:
163
+ return json.dumps(value, ensure_ascii=False)
164
+
165
+
166
+ def _export_sort_key(name: str) -> tuple[int, str]:
167
+ if name.isupper():
168
+ group = 0
169
+ elif name[:1].isupper():
170
+ group = 1
171
+ else:
172
+ group = 2
173
+ return group, name
@@ -0,0 +1,63 @@
1
+ """Discover ordered, forward-only SQL migrations for disposable schema checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ _MIGRATION_NAME = re.compile(
10
+ r"^(?P<version>[0-9]+)_(?P<description>[a-z0-9]+(?:[_-][a-z0-9]+)*)\.sql$"
11
+ )
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class Migration:
16
+ path: Path
17
+ version: int
18
+ version_text: str
19
+ sql: str
20
+
21
+
22
+ def load_migrations(directory: Path) -> list[Migration]:
23
+ """Load one top-level directory of monotonically numbered SQL migrations."""
24
+
25
+ if not directory.is_dir():
26
+ raise ValueError(f"{directory}: expected a migration directory")
27
+
28
+ paths = sorted(directory.glob("*.sql"))
29
+ if not paths:
30
+ raise ValueError(f"{directory}: no migration files found")
31
+
32
+ migrations: list[Migration] = []
33
+ versions: dict[int, Path] = {}
34
+ widths: set[int] = set()
35
+ for path in paths:
36
+ matched = _MIGRATION_NAME.fullmatch(path.name)
37
+ if matched is None:
38
+ raise ValueError(
39
+ f"{path}: expected '<number>_<description>.sql' with a lowercase description"
40
+ )
41
+
42
+ version_text = matched.group("version")
43
+ version = int(version_text)
44
+ if version == 0:
45
+ raise ValueError(f"{path}: migration version must be positive")
46
+ previous = versions.get(version)
47
+ if previous is not None:
48
+ raise ValueError(f"duplicate migration version {version}: {previous} and {path}")
49
+
50
+ sql = path.read_text(encoding="utf-8")
51
+ if not sql.strip():
52
+ raise ValueError(f"{path}: migration SQL cannot be empty")
53
+
54
+ versions[version] = path
55
+ widths.add(len(version_text))
56
+ migrations.append(Migration(path, version, version_text, sql))
57
+
58
+ if len(widths) != 1:
59
+ raise ValueError(
60
+ f"{directory}: migration versions must use one fixed width for deterministic ordering"
61
+ )
62
+
63
+ return sorted(migrations, key=lambda migration: migration.version)
hayate_sql/parser.py ADDED
@@ -0,0 +1,153 @@
1
+ """Parser for one-query-per-file SQL contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import keyword
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from .contract import Cardinality, Column, Parameter, Query
10
+
11
+ _NAME = re.compile(r"^\s*--\s*name:\s*([a-z][a-z0-9]*(?:_[a-z0-9]+)*)\s+:(one\?|one|many|exec)\s*$")
12
+ _PARAM = re.compile(
13
+ r"^\s*--\s*param:\s*([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z][A-Za-z0-9_]*(?:\?)?)\s*$"
14
+ )
15
+ _COLUMN = re.compile(
16
+ r"^\s*--\s*column:\s*([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z][A-Za-z0-9_]*(?:\?)?)\s*$"
17
+ )
18
+ _TIMEOUT = re.compile(r"^\s*--\s*timeout:\s*(\d+)(ms|s)\s*$")
19
+ _DIRECTIVE = re.compile(r"^\s*--\s*(name|param|column|timeout):")
20
+ _ALLOWED_TYPES = {
21
+ "Any",
22
+ "Decimal",
23
+ "UUID",
24
+ "bool",
25
+ "bytes",
26
+ "date",
27
+ "datetime",
28
+ "float",
29
+ "int",
30
+ "object",
31
+ "str",
32
+ }
33
+
34
+
35
+ class QueryParseError(ValueError):
36
+ def __init__(self, source: str, line: int, message: str) -> None:
37
+ super().__init__(f"{source}:{line}: {message}")
38
+ self.source = source
39
+ self.line = line
40
+ self.message = message
41
+
42
+
43
+ def parse_query(text: str, *, source: str = "<string>") -> Query:
44
+ name: str | None = None
45
+ cardinality: Cardinality | None = None
46
+ parameters: list[Parameter] = []
47
+ columns: list[Column] = []
48
+ timeout_ms: int | None = None
49
+ sql_lines: list[str] = []
50
+ in_header = True
51
+
52
+ for line_number, line in enumerate(text.splitlines(keepends=True), 1):
53
+ matched = _NAME.match(line)
54
+ if in_header and matched:
55
+ if name is not None:
56
+ raise QueryParseError(source, line_number, "duplicate name directive")
57
+ name = matched.group(1)
58
+ _validate_identifier(name, source, line_number, "query")
59
+ cardinality = Cardinality(matched.group(2))
60
+ continue
61
+
62
+ matched = _PARAM.match(line)
63
+ if in_header and matched:
64
+ parameter_name, annotation = matched.groups()
65
+ _validate_identifier(parameter_name, source, line_number, "parameter")
66
+ _validate_annotation(annotation, source, line_number)
67
+ parameters.append(Parameter(parameter_name, annotation))
68
+ continue
69
+
70
+ matched = _COLUMN.match(line)
71
+ if in_header and matched:
72
+ column_name, annotation = matched.groups()
73
+ _validate_identifier(column_name, source, line_number, "column")
74
+ _validate_annotation(annotation, source, line_number)
75
+ columns.append(Column(column_name, annotation))
76
+ continue
77
+
78
+ matched = _TIMEOUT.match(line)
79
+ if in_header and matched:
80
+ if timeout_ms is not None:
81
+ raise QueryParseError(source, line_number, "duplicate timeout directive")
82
+ amount = int(matched.group(1))
83
+ timeout_ms = amount if matched.group(2) == "ms" else amount * 1000
84
+ if timeout_ms == 0:
85
+ raise QueryParseError(source, line_number, "timeout must be positive")
86
+ continue
87
+
88
+ if in_header and _DIRECTIVE.match(line):
89
+ raise QueryParseError(source, line_number, "malformed query directive")
90
+
91
+ if line.strip() and not line.lstrip().startswith("--"):
92
+ in_header = False
93
+ sql_lines.append(line)
94
+
95
+ if name is None or cardinality is None:
96
+ raise QueryParseError(source, 1, "missing '-- name: query_name :cardinality'")
97
+
98
+ sql = "".join(sql_lines).strip()
99
+ try:
100
+ return Query(
101
+ name=name,
102
+ sql=sql,
103
+ cardinality=cardinality,
104
+ parameters=tuple(parameters),
105
+ columns=tuple(columns),
106
+ timeout_ms=timeout_ms,
107
+ )
108
+ except ValueError as error:
109
+ raise QueryParseError(source, 1, str(error)) from error
110
+
111
+
112
+ def load_queries(paths: list[Path]) -> list[tuple[Path, Query]]:
113
+ files: list[Path] = []
114
+ for path in paths:
115
+ if path.is_dir():
116
+ files.extend(sorted(path.rglob("*.sql")))
117
+ elif path.suffix == ".sql":
118
+ files.append(path)
119
+ else:
120
+ raise ValueError(f"{path}: expected a .sql file or directory")
121
+ if not files:
122
+ raise ValueError("no .sql query files found")
123
+
124
+ loaded = [(path, parse_query(path.read_text(), source=str(path))) for path in files]
125
+ names: dict[str, Path] = {}
126
+ for path, query in loaded:
127
+ previous = names.get(query.name)
128
+ if previous is not None:
129
+ raise ValueError(f"duplicate query name {query.name!r}: {previous} and {path}")
130
+ names[query.name] = path
131
+ return loaded
132
+
133
+
134
+ def python_annotation(annotation: str) -> str:
135
+ if annotation.endswith("?"):
136
+ return f"{annotation[:-1]} | None"
137
+ return annotation
138
+
139
+
140
+ def _validate_identifier(name: str, source: str, line: int, kind: str) -> None:
141
+ if keyword.iskeyword(name):
142
+ raise QueryParseError(source, line, f"{kind} name {name!r} is a Python keyword")
143
+ if kind == "parameter" and name == "db":
144
+ raise QueryParseError(
145
+ source, line, "parameter name 'db' is reserved by generated functions"
146
+ )
147
+
148
+
149
+ def _validate_annotation(annotation: str, source: str, line: int) -> None:
150
+ base = annotation.removesuffix("?")
151
+ if base not in _ALLOWED_TYPES:
152
+ allowed = ", ".join(sorted(_ALLOWED_TYPES))
153
+ raise QueryParseError(source, line, f"unsupported type {base!r}; choose one of {allowed}")
hayate_sql/py.typed ADDED
@@ -0,0 +1 @@
1
+