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 +36 -0
- hayate_sql/__main__.py +105 -0
- hayate_sql/adapters/__init__.py +7 -0
- hayate_sql/adapters/d1.py +72 -0
- hayate_sql/adapters/postgres.py +36 -0
- hayate_sql/adapters/sqlite.py +104 -0
- hayate_sql/check.py +149 -0
- hayate_sql/contract.py +111 -0
- hayate_sql/database.py +135 -0
- hayate_sql/generate.py +173 -0
- hayate_sql/migrations.py +63 -0
- hayate_sql/parser.py +153 -0
- hayate_sql/py.typed +1 -0
- hayate_sql-0.1.0.dist-info/METADATA +227 -0
- hayate_sql-0.1.0.dist-info/RECORD +18 -0
- hayate_sql-0.1.0.dist-info/WHEEL +4 -0
- hayate_sql-0.1.0.dist-info/entry_points.txt +3 -0
- hayate_sql-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
hayate_sql/migrations.py
ADDED
|
@@ -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
|
+
|