sqlstratum 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.
- sqlstratum/__init__.py +32 -0
- sqlstratum/ast.py +96 -0
- sqlstratum/compile.py +156 -0
- sqlstratum/dsl.py +150 -0
- sqlstratum/expr.py +115 -0
- sqlstratum/hydrate.py +55 -0
- sqlstratum/meta.py +112 -0
- sqlstratum/runner.py +141 -0
- sqlstratum/types.py +44 -0
- sqlstratum-0.1.0.dist-info/METADATA +179 -0
- sqlstratum-0.1.0.dist-info/RECORD +14 -0
- sqlstratum-0.1.0.dist-info/WHEEL +5 -0
- sqlstratum-0.1.0.dist-info/licenses/LICENSE +21 -0
- sqlstratum-0.1.0.dist-info/top_level.txt +1 -0
sqlstratum/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""sqlstratum: minimal SQL AST + compiler + sqlite runner."""
|
|
2
|
+
from .dsl import SELECT, INSERT, UPDATE, DELETE, OR, AND, NOT
|
|
3
|
+
from .expr import COUNT, SUM, AVG, MIN, MAX
|
|
4
|
+
from .meta import Table, Column, col
|
|
5
|
+
from .compile import compile
|
|
6
|
+
from .runner import Runner
|
|
7
|
+
from .types import Expression, HydrationTarget, Hydrator, Predicate, Source
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"SELECT",
|
|
11
|
+
"INSERT",
|
|
12
|
+
"UPDATE",
|
|
13
|
+
"DELETE",
|
|
14
|
+
"OR",
|
|
15
|
+
"AND",
|
|
16
|
+
"NOT",
|
|
17
|
+
"COUNT",
|
|
18
|
+
"SUM",
|
|
19
|
+
"AVG",
|
|
20
|
+
"MIN",
|
|
21
|
+
"MAX",
|
|
22
|
+
"Table",
|
|
23
|
+
"Column",
|
|
24
|
+
"col",
|
|
25
|
+
"compile",
|
|
26
|
+
"Runner",
|
|
27
|
+
"Expression",
|
|
28
|
+
"HydrationTarget",
|
|
29
|
+
"Hydrator",
|
|
30
|
+
"Predicate",
|
|
31
|
+
"Source",
|
|
32
|
+
]
|
sqlstratum/ast.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""AST node definitions for sqlstratum."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, Iterable, Optional, Sequence, Tuple, TypeVar
|
|
6
|
+
|
|
7
|
+
from .meta import Column
|
|
8
|
+
from .expr import OrderSpec
|
|
9
|
+
from .types import Expression, HydrationTarget, Predicate, Source
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Compiled:
|
|
14
|
+
sql: str
|
|
15
|
+
params: Dict[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class SelectQuery:
|
|
20
|
+
projections: Tuple[Expression, ...]
|
|
21
|
+
from_: Optional[Source]
|
|
22
|
+
joins: Tuple["Join", ...]
|
|
23
|
+
where: Tuple[Predicate, ...]
|
|
24
|
+
group_by: Tuple[Expression, ...]
|
|
25
|
+
having: Tuple[Predicate, ...]
|
|
26
|
+
order_by: Tuple[OrderSpec, ...]
|
|
27
|
+
limit: Optional[int]
|
|
28
|
+
offset: Optional[int]
|
|
29
|
+
distinct: bool
|
|
30
|
+
hydrate: HydrationTarget
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Join:
|
|
35
|
+
kind: str # "INNER" or "LEFT"
|
|
36
|
+
source: Source
|
|
37
|
+
on: Predicate
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class InsertQuery:
|
|
42
|
+
table: Any
|
|
43
|
+
values: Tuple[Tuple[str, Any], ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class UpdateQuery:
|
|
48
|
+
table: Any
|
|
49
|
+
values: Tuple[Tuple[str, Any], ...]
|
|
50
|
+
where: Tuple[Predicate, ...]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class DeleteQuery:
|
|
55
|
+
table: Any
|
|
56
|
+
where: Tuple[Predicate, ...]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Subquery:
|
|
61
|
+
query: SelectQuery
|
|
62
|
+
alias: str
|
|
63
|
+
c: Any = None
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
object.__setattr__(self, "c", _SubqueryColumnAccessor(self.alias))
|
|
67
|
+
|
|
68
|
+
def __getattr__(self, item: str) -> Column:
|
|
69
|
+
return getattr(self.c, item)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class _SubqueryTable:
|
|
74
|
+
name: str
|
|
75
|
+
alias: Optional[str] = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _SubqueryColumnAccessor:
|
|
79
|
+
def __init__(self, alias: str) -> None:
|
|
80
|
+
self._alias = alias
|
|
81
|
+
|
|
82
|
+
def __getattr__(self, item: str) -> Column:
|
|
83
|
+
return Column(name=item, py_type=object, table=_SubqueryTable(self._alias))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ExecutionResult:
|
|
88
|
+
rowcount: int
|
|
89
|
+
lastrowid: Optional[int]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
T = TypeVar("T")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def tupled(items: Iterable[T]) -> Tuple[T, ...]:
|
|
96
|
+
return tuple(items)
|
sqlstratum/compile.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""SQLite compiler for sqlstratum AST."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, Iterable, List
|
|
6
|
+
|
|
7
|
+
from . import ast
|
|
8
|
+
from .expr import (
|
|
9
|
+
AliasExpr,
|
|
10
|
+
BinaryPredicate,
|
|
11
|
+
Function,
|
|
12
|
+
Literal,
|
|
13
|
+
LogicalPredicate,
|
|
14
|
+
NotPredicate,
|
|
15
|
+
OrderSpec,
|
|
16
|
+
UnaryPredicate,
|
|
17
|
+
)
|
|
18
|
+
from .meta import Column, Table
|
|
19
|
+
from .types import Predicate
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Compiled(ast.Compiled):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compile(query: Any, dialect: str = "sqlite") -> Compiled:
|
|
28
|
+
if dialect != "sqlite":
|
|
29
|
+
raise ValueError("Only sqlite dialect is supported in v0.1")
|
|
30
|
+
compiler = _Compiler()
|
|
31
|
+
sql = compiler.compile_query(query)
|
|
32
|
+
return Compiled(sql=sql, params=compiler.params)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Compiler:
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.params: Dict[str, Any] = {}
|
|
38
|
+
self._param_index = 0
|
|
39
|
+
|
|
40
|
+
def compile_query(self, query: Any) -> str:
|
|
41
|
+
if isinstance(query, ast.SelectQuery):
|
|
42
|
+
return self._compile_select(query)
|
|
43
|
+
if isinstance(query, ast.InsertQuery):
|
|
44
|
+
return self._compile_insert(query)
|
|
45
|
+
if isinstance(query, ast.UpdateQuery):
|
|
46
|
+
return self._compile_update(query)
|
|
47
|
+
if isinstance(query, ast.DeleteQuery):
|
|
48
|
+
return self._compile_delete(query)
|
|
49
|
+
raise TypeError(f"Unsupported query type: {type(query)}")
|
|
50
|
+
|
|
51
|
+
def _compile_select(self, query: ast.SelectQuery) -> str:
|
|
52
|
+
parts: List[str] = []
|
|
53
|
+
distinct = "DISTINCT " if query.distinct else ""
|
|
54
|
+
projections = ", ".join(self._compile_projection(p) for p in query.projections)
|
|
55
|
+
parts.append(f"SELECT {distinct}{projections}")
|
|
56
|
+
if query.from_ is not None:
|
|
57
|
+
parts.append("FROM " + self._compile_source(query.from_))
|
|
58
|
+
for join in query.joins:
|
|
59
|
+
join_sql = "JOIN" if join.kind == "INNER" else "LEFT JOIN"
|
|
60
|
+
parts.append(f"{join_sql} {self._compile_source(join.source)} ON {self._compile_predicate(join.on)}")
|
|
61
|
+
if query.where:
|
|
62
|
+
parts.append("WHERE " + self._compile_and_list(query.where))
|
|
63
|
+
if query.group_by:
|
|
64
|
+
parts.append("GROUP BY " + ", ".join(self._compile_expr(e) for e in query.group_by))
|
|
65
|
+
if query.having:
|
|
66
|
+
parts.append("HAVING " + self._compile_and_list(query.having))
|
|
67
|
+
if query.order_by:
|
|
68
|
+
parts.append("ORDER BY " + ", ".join(self._compile_order(o) for o in query.order_by))
|
|
69
|
+
if query.limit is not None:
|
|
70
|
+
parts.append("LIMIT " + self._bind(query.limit))
|
|
71
|
+
if query.offset is not None:
|
|
72
|
+
parts.append("OFFSET " + self._bind(query.offset))
|
|
73
|
+
return " ".join(parts)
|
|
74
|
+
|
|
75
|
+
def _compile_insert(self, query: ast.InsertQuery) -> str:
|
|
76
|
+
columns = ", ".join(self._quote(k) for k, _ in query.values)
|
|
77
|
+
values = ", ".join(self._bind(v) for _, v in query.values)
|
|
78
|
+
return f"INSERT INTO {self._compile_table(query.table)} ({columns}) VALUES ({values})"
|
|
79
|
+
|
|
80
|
+
def _compile_update(self, query: ast.UpdateQuery) -> str:
|
|
81
|
+
sets = ", ".join(f"{self._quote(k)} = {self._bind(v)}" for k, v in query.values)
|
|
82
|
+
parts = [f"UPDATE {self._compile_table(query.table)} SET {sets}"]
|
|
83
|
+
if query.where:
|
|
84
|
+
parts.append("WHERE " + self._compile_and_list(query.where))
|
|
85
|
+
return " ".join(parts)
|
|
86
|
+
|
|
87
|
+
def _compile_delete(self, query: ast.DeleteQuery) -> str:
|
|
88
|
+
parts = [f"DELETE FROM {self._compile_table(query.table)}"]
|
|
89
|
+
if query.where:
|
|
90
|
+
parts.append("WHERE " + self._compile_and_list(query.where))
|
|
91
|
+
return " ".join(parts)
|
|
92
|
+
|
|
93
|
+
def _compile_projection(self, expr: Any) -> str:
|
|
94
|
+
return self._compile_expr(expr)
|
|
95
|
+
|
|
96
|
+
def _compile_source(self, source: Any) -> str:
|
|
97
|
+
if isinstance(source, Table):
|
|
98
|
+
return self._compile_table(source)
|
|
99
|
+
if isinstance(source, ast.Subquery):
|
|
100
|
+
return f"({self._compile_select(source.query)}) AS {self._quote(source.alias)}"
|
|
101
|
+
raise TypeError(f"Unsupported source type: {type(source)}")
|
|
102
|
+
|
|
103
|
+
def _compile_table(self, table: Table) -> str:
|
|
104
|
+
if table.alias:
|
|
105
|
+
return f"{self._quote(table.name)} AS {self._quote(table.alias)}"
|
|
106
|
+
return self._quote(table.name)
|
|
107
|
+
|
|
108
|
+
def _compile_expr(self, expr: Any) -> str:
|
|
109
|
+
if isinstance(expr, Column):
|
|
110
|
+
return self._compile_column(expr)
|
|
111
|
+
if isinstance(expr, AliasExpr):
|
|
112
|
+
return f"{self._compile_expr(expr.expr)} AS {self._quote(expr.alias)}"
|
|
113
|
+
if isinstance(expr, Function):
|
|
114
|
+
args = ", ".join(self._compile_expr(a) for a in expr.args)
|
|
115
|
+
return f"{expr.name}({args})"
|
|
116
|
+
if isinstance(expr, OrderSpec):
|
|
117
|
+
return self._compile_order(expr)
|
|
118
|
+
if isinstance(expr, Literal):
|
|
119
|
+
return self._bind(expr.value)
|
|
120
|
+
if isinstance(expr, ast.Subquery):
|
|
121
|
+
return f"({self._compile_select(expr.query)})"
|
|
122
|
+
return self._compile_predicate(expr)
|
|
123
|
+
|
|
124
|
+
def _compile_predicate(self, pred: Predicate) -> str:
|
|
125
|
+
if isinstance(pred, BinaryPredicate):
|
|
126
|
+
return f"{self._compile_expr(pred.left)} {pred.op} {self._compile_expr(pred.right)}"
|
|
127
|
+
if isinstance(pred, UnaryPredicate):
|
|
128
|
+
return f"{self._compile_expr(pred.expr)} {pred.op}"
|
|
129
|
+
if isinstance(pred, LogicalPredicate):
|
|
130
|
+
inner = f" {pred.op} ".join(self._compile_predicate(p) for p in pred.predicates)
|
|
131
|
+
return f"({inner})"
|
|
132
|
+
if isinstance(pred, NotPredicate):
|
|
133
|
+
return f"NOT ({self._compile_predicate(pred.predicate)})"
|
|
134
|
+
raise TypeError(f"Unsupported predicate type: {type(pred)}")
|
|
135
|
+
|
|
136
|
+
def _compile_and_list(self, preds: Iterable[Predicate]) -> str:
|
|
137
|
+
parts = [self._compile_predicate(p) for p in preds]
|
|
138
|
+
return " AND ".join(parts)
|
|
139
|
+
|
|
140
|
+
def _compile_order(self, order: OrderSpec) -> str:
|
|
141
|
+
return f"{self._compile_expr(order.expr)} {order.direction}"
|
|
142
|
+
|
|
143
|
+
def _compile_column(self, col: Column) -> str:
|
|
144
|
+
if col.table.alias:
|
|
145
|
+
return f"{self._quote(col.table.alias)}.{self._quote(col.name)}"
|
|
146
|
+
return f"{self._quote(col.table.name)}.{self._quote(col.name)}"
|
|
147
|
+
|
|
148
|
+
def _quote(self, ident: str) -> str:
|
|
149
|
+
escaped = ident.replace('"', '""')
|
|
150
|
+
return f'"{escaped}"'
|
|
151
|
+
|
|
152
|
+
def _bind(self, value: Any) -> str:
|
|
153
|
+
name = f"p{self._param_index}"
|
|
154
|
+
self._param_index += 1
|
|
155
|
+
self.params[name] = value
|
|
156
|
+
return f":{name}"
|
sqlstratum/dsl.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Public DSL constructors and query chaining."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import Any, Tuple
|
|
6
|
+
|
|
7
|
+
from .ast import DeleteQuery, InsertQuery, Join, SelectQuery, Subquery, UpdateQuery, tupled
|
|
8
|
+
from .expr import LogicalPredicate, NotPredicate, OrderSpec
|
|
9
|
+
from .meta import Table
|
|
10
|
+
from .types import Expression, HydrationTarget, Predicate, Source
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def SELECT(*projections: Expression) -> SelectQuery:
|
|
14
|
+
return SelectQuery(
|
|
15
|
+
projections=tupled(projections),
|
|
16
|
+
from_=None,
|
|
17
|
+
joins=tuple(),
|
|
18
|
+
where=tuple(),
|
|
19
|
+
group_by=tuple(),
|
|
20
|
+
having=tuple(),
|
|
21
|
+
order_by=tuple(),
|
|
22
|
+
limit=None,
|
|
23
|
+
offset=None,
|
|
24
|
+
distinct=False,
|
|
25
|
+
hydrate=None,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def INSERT(table: Table) -> "InsertBuilder":
|
|
30
|
+
return InsertBuilder(table)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def UPDATE(table: Table) -> "UpdateBuilder":
|
|
34
|
+
return UpdateBuilder(table)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def DELETE(table: Table) -> "DeleteBuilder":
|
|
38
|
+
return DeleteBuilder(table)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def OR(*predicates: Predicate) -> LogicalPredicate:
|
|
42
|
+
return LogicalPredicate("OR", tupled(predicates))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def AND(*predicates: Predicate) -> LogicalPredicate:
|
|
46
|
+
return LogicalPredicate("AND", tupled(predicates))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def NOT(predicate: Predicate) -> NotPredicate:
|
|
50
|
+
return NotPredicate(predicate)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Query chaining methods added via monkey-patching style functions
|
|
54
|
+
|
|
55
|
+
def _from(self: SelectQuery, source: Source) -> SelectQuery:
|
|
56
|
+
return replace(self, from_=source)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _join(self: SelectQuery, source: Source, ON: Predicate) -> SelectQuery:
|
|
60
|
+
joins = self.joins + (Join("INNER", source, ON),)
|
|
61
|
+
return replace(self, joins=joins)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _left_join(self: SelectQuery, source: Source, ON: Predicate) -> SelectQuery:
|
|
65
|
+
joins = self.joins + (Join("LEFT", source, ON),)
|
|
66
|
+
return replace(self, joins=joins)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _where(self: SelectQuery, *predicates: Predicate) -> SelectQuery:
|
|
70
|
+
where = self.where + tupled(predicates)
|
|
71
|
+
return replace(self, where=where)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _group_by(self: SelectQuery, *cols: Expression) -> SelectQuery:
|
|
75
|
+
return replace(self, group_by=self.group_by + tupled(cols))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _having(self: SelectQuery, *predicates: Predicate) -> SelectQuery:
|
|
79
|
+
return replace(self, having=self.having + tupled(predicates))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _order_by(self: SelectQuery, *order_specs: OrderSpec) -> SelectQuery:
|
|
83
|
+
return replace(self, order_by=self.order_by + tupled(order_specs))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _limit(self: SelectQuery, n: int) -> SelectQuery:
|
|
87
|
+
return replace(self, limit=n)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _offset(self: SelectQuery, n: int) -> SelectQuery:
|
|
91
|
+
return replace(self, offset=n)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _distinct(self: SelectQuery) -> SelectQuery:
|
|
95
|
+
return replace(self, distinct=True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _as(self: SelectQuery, alias: str) -> Subquery:
|
|
99
|
+
return Subquery(self, alias)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _hydrate(self: SelectQuery, target: HydrationTarget) -> SelectQuery:
|
|
103
|
+
return replace(self, hydrate=target)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
SelectQuery.FROM = _from # type: ignore[attr-defined]
|
|
107
|
+
SelectQuery.JOIN = _join # type: ignore[attr-defined]
|
|
108
|
+
SelectQuery.LEFT_JOIN = _left_join # type: ignore[attr-defined]
|
|
109
|
+
SelectQuery.WHERE = _where # type: ignore[attr-defined]
|
|
110
|
+
SelectQuery.GROUP_BY = _group_by # type: ignore[attr-defined]
|
|
111
|
+
SelectQuery.HAVING = _having # type: ignore[attr-defined]
|
|
112
|
+
SelectQuery.ORDER_BY = _order_by # type: ignore[attr-defined]
|
|
113
|
+
SelectQuery.LIMIT = _limit # type: ignore[attr-defined]
|
|
114
|
+
SelectQuery.OFFSET = _offset # type: ignore[attr-defined]
|
|
115
|
+
SelectQuery.DISTINCT = _distinct # type: ignore[attr-defined]
|
|
116
|
+
SelectQuery.AS = _as # type: ignore[attr-defined]
|
|
117
|
+
SelectQuery.HYDRATE = _hydrate # type: ignore[attr-defined]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class InsertBuilder:
|
|
121
|
+
def __init__(self, table: Table):
|
|
122
|
+
self.table = table
|
|
123
|
+
|
|
124
|
+
def VALUES(self, **values: Any) -> InsertQuery:
|
|
125
|
+
return InsertQuery(self.table, tuple(values.items()))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class UpdateBuilder:
|
|
129
|
+
def __init__(self, table: Table):
|
|
130
|
+
self.table = table
|
|
131
|
+
|
|
132
|
+
def SET(self, **values: Any) -> "UpdateWhereBuilder":
|
|
133
|
+
return UpdateWhereBuilder(self.table, tuple(values.items()))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class UpdateWhereBuilder:
|
|
137
|
+
def __init__(self, table: Table, values: Tuple[Tuple[str, Any], ...]):
|
|
138
|
+
self.table = table
|
|
139
|
+
self.values = values
|
|
140
|
+
|
|
141
|
+
def WHERE(self, *predicates: Predicate) -> UpdateQuery:
|
|
142
|
+
return UpdateQuery(self.table, self.values, tupled(predicates))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class DeleteBuilder:
|
|
146
|
+
def __init__(self, table: Table):
|
|
147
|
+
self.table = table
|
|
148
|
+
|
|
149
|
+
def WHERE(self, *predicates: Predicate) -> DeleteQuery:
|
|
150
|
+
return DeleteQuery(self.table, tupled(predicates))
|
sqlstratum/expr.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Expression and predicate nodes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Expr:
|
|
9
|
+
def __eq__(self, other: Any) -> "BinaryPredicate": # type: ignore[override]
|
|
10
|
+
return BinaryPredicate(self, "=", ensure_expr(other))
|
|
11
|
+
|
|
12
|
+
def __ne__(self, other: Any) -> "BinaryPredicate": # type: ignore[override]
|
|
13
|
+
return BinaryPredicate(self, "!=", ensure_expr(other))
|
|
14
|
+
|
|
15
|
+
def __lt__(self, other: Any) -> "BinaryPredicate":
|
|
16
|
+
return BinaryPredicate(self, "<", ensure_expr(other))
|
|
17
|
+
|
|
18
|
+
def __le__(self, other: Any) -> "BinaryPredicate":
|
|
19
|
+
return BinaryPredicate(self, "<=", ensure_expr(other))
|
|
20
|
+
|
|
21
|
+
def __gt__(self, other: Any) -> "BinaryPredicate":
|
|
22
|
+
return BinaryPredicate(self, ">", ensure_expr(other))
|
|
23
|
+
|
|
24
|
+
def __ge__(self, other: Any) -> "BinaryPredicate":
|
|
25
|
+
return BinaryPredicate(self, ">=", ensure_expr(other))
|
|
26
|
+
|
|
27
|
+
def AS(self, alias: str) -> "AliasExpr":
|
|
28
|
+
return AliasExpr(self, alias)
|
|
29
|
+
|
|
30
|
+
def ASC(self) -> "OrderSpec":
|
|
31
|
+
return OrderSpec(self, "ASC")
|
|
32
|
+
|
|
33
|
+
def DESC(self) -> "OrderSpec":
|
|
34
|
+
return OrderSpec(self, "DESC")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class AliasExpr(Expr):
|
|
39
|
+
expr: Expr
|
|
40
|
+
alias: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Literal(Expr):
|
|
45
|
+
value: Any
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class BinaryPredicate:
|
|
50
|
+
left: Expr
|
|
51
|
+
op: str
|
|
52
|
+
right: Expr
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class UnaryPredicate:
|
|
57
|
+
expr: Expr
|
|
58
|
+
op: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class LogicalPredicate:
|
|
63
|
+
op: str # "AND" or "OR"
|
|
64
|
+
predicates: Tuple["Predicate", ...]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class NotPredicate:
|
|
69
|
+
predicate: "Predicate"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Function(Expr):
|
|
74
|
+
name: str
|
|
75
|
+
args: tuple
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class OrderSpec:
|
|
80
|
+
expr: Expr
|
|
81
|
+
direction: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ensure_expr(value: Any) -> Expr:
|
|
85
|
+
from .meta import Column
|
|
86
|
+
if isinstance(value, Expr) or isinstance(value, Column):
|
|
87
|
+
return value # type: ignore[return-value]
|
|
88
|
+
return Literal(value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Aggregate helpers
|
|
92
|
+
|
|
93
|
+
def COUNT(expr: Optional[Expr] = None) -> Function:
|
|
94
|
+
if expr is None:
|
|
95
|
+
return Function("COUNT", (Literal(1),))
|
|
96
|
+
return Function("COUNT", (ensure_expr(expr),))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def SUM(expr: Expr) -> Function:
|
|
100
|
+
return Function("SUM", (ensure_expr(expr),))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def AVG(expr: Expr) -> Function:
|
|
104
|
+
return Function("AVG", (ensure_expr(expr),))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def MIN(expr: Expr) -> Function:
|
|
108
|
+
return Function("MIN", (ensure_expr(expr),))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def MAX(expr: Expr) -> Function:
|
|
112
|
+
return Function("MAX", (ensure_expr(expr),))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
Predicate = BinaryPredicate | UnaryPredicate | LogicalPredicate | NotPredicate
|
sqlstratum/hydrate.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Hydration utilities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import is_dataclass
|
|
5
|
+
from typing import Any, Dict, Iterable, List, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
from .expr import AliasExpr, Function
|
|
8
|
+
from .meta import Column
|
|
9
|
+
from .types import HydrationTarget
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HydrationError(ValueError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def projection_keys(projections: Sequence[Any]) -> List[str]:
|
|
17
|
+
keys: List[str] = []
|
|
18
|
+
for proj in projections:
|
|
19
|
+
key = _projection_key(proj)
|
|
20
|
+
if key in keys:
|
|
21
|
+
raise HydrationError(f"Duplicate projection key '{key}'. Use AS() to disambiguate.")
|
|
22
|
+
keys.append(key)
|
|
23
|
+
return keys
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _projection_key(proj: Any) -> str:
|
|
27
|
+
if isinstance(proj, AliasExpr):
|
|
28
|
+
return proj.alias
|
|
29
|
+
if isinstance(proj, Column):
|
|
30
|
+
return proj.name
|
|
31
|
+
if isinstance(proj, Function):
|
|
32
|
+
raise HydrationError("Aggregate expressions require AS('alias') for hydration")
|
|
33
|
+
raise HydrationError("Projection requires AS('alias') for hydration")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def hydrate_rows(
|
|
37
|
+
rows: Iterable[Mapping[str, Any]],
|
|
38
|
+
projections: Sequence[Any],
|
|
39
|
+
target: HydrationTarget,
|
|
40
|
+
) -> List[Any]:
|
|
41
|
+
keys = projection_keys(projections)
|
|
42
|
+
mapped: List[Dict[str, Any]] = []
|
|
43
|
+
for row in rows:
|
|
44
|
+
mapped.append({k: row[k] for k in keys})
|
|
45
|
+
|
|
46
|
+
if target is None or target is dict:
|
|
47
|
+
return mapped
|
|
48
|
+
|
|
49
|
+
if is_dataclass(target):
|
|
50
|
+
return [target(**m) for m in mapped]
|
|
51
|
+
|
|
52
|
+
if callable(target):
|
|
53
|
+
return [target(m) for m in mapped]
|
|
54
|
+
|
|
55
|
+
raise HydrationError("Unsupported hydration target")
|
sqlstratum/meta.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Metadata objects: Table and Column."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
6
|
+
|
|
7
|
+
from .expr import Expr
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Column(Expr):
|
|
11
|
+
name: str
|
|
12
|
+
py_type: type
|
|
13
|
+
table: "Table"
|
|
14
|
+
|
|
15
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
16
|
+
return f"Column({self.table.name}.{self.name})"
|
|
17
|
+
|
|
18
|
+
# Comparison operators build predicates (defined in expr.py)
|
|
19
|
+
def __eq__(self, other: Any) -> "BinaryPredicate": # type: ignore[override]
|
|
20
|
+
from .expr import BinaryPredicate, ensure_expr
|
|
21
|
+
return BinaryPredicate(self, "=", ensure_expr(other))
|
|
22
|
+
|
|
23
|
+
def __ne__(self, other: Any) -> "BinaryPredicate": # type: ignore[override]
|
|
24
|
+
from .expr import BinaryPredicate, ensure_expr
|
|
25
|
+
return BinaryPredicate(self, "!=", ensure_expr(other))
|
|
26
|
+
|
|
27
|
+
def __lt__(self, other: Any) -> "BinaryPredicate":
|
|
28
|
+
from .expr import BinaryPredicate, ensure_expr
|
|
29
|
+
return BinaryPredicate(self, "<", ensure_expr(other))
|
|
30
|
+
|
|
31
|
+
def __le__(self, other: Any) -> "BinaryPredicate":
|
|
32
|
+
from .expr import BinaryPredicate, ensure_expr
|
|
33
|
+
return BinaryPredicate(self, "<=", ensure_expr(other))
|
|
34
|
+
|
|
35
|
+
def __gt__(self, other: Any) -> "BinaryPredicate":
|
|
36
|
+
from .expr import BinaryPredicate, ensure_expr
|
|
37
|
+
return BinaryPredicate(self, ">", ensure_expr(other))
|
|
38
|
+
|
|
39
|
+
def __ge__(self, other: Any) -> "BinaryPredicate":
|
|
40
|
+
from .expr import BinaryPredicate, ensure_expr
|
|
41
|
+
return BinaryPredicate(self, ">=", ensure_expr(other))
|
|
42
|
+
|
|
43
|
+
def is_null(self) -> "UnaryPredicate":
|
|
44
|
+
from .expr import UnaryPredicate
|
|
45
|
+
return UnaryPredicate(self, "IS NULL")
|
|
46
|
+
|
|
47
|
+
def is_not_null(self) -> "UnaryPredicate":
|
|
48
|
+
from .expr import UnaryPredicate
|
|
49
|
+
return UnaryPredicate(self, "IS NOT NULL")
|
|
50
|
+
|
|
51
|
+
def contains(self, text: str) -> "BinaryPredicate":
|
|
52
|
+
from .expr import BinaryPredicate, Literal
|
|
53
|
+
return BinaryPredicate(self, "LIKE", Literal(f"%{text}%"))
|
|
54
|
+
|
|
55
|
+
def is_true(self) -> "BinaryPredicate":
|
|
56
|
+
from .expr import BinaryPredicate, Literal
|
|
57
|
+
return BinaryPredicate(self, "=", Literal(True))
|
|
58
|
+
|
|
59
|
+
def is_false(self) -> "BinaryPredicate":
|
|
60
|
+
from .expr import BinaryPredicate, Literal
|
|
61
|
+
return BinaryPredicate(self, "=", Literal(False))
|
|
62
|
+
|
|
63
|
+
def ASC(self) -> "OrderSpec":
|
|
64
|
+
from .expr import OrderSpec
|
|
65
|
+
return OrderSpec(self, "ASC")
|
|
66
|
+
|
|
67
|
+
def DESC(self) -> "OrderSpec":
|
|
68
|
+
from .expr import OrderSpec
|
|
69
|
+
return OrderSpec(self, "DESC")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Table:
|
|
73
|
+
def __init__(self, name: str, *columns: Column, alias: Optional[str] = None, columns_list: Optional[Iterable[Column]] = None):
|
|
74
|
+
self.name = name
|
|
75
|
+
self.alias = alias
|
|
76
|
+
if columns_list is not None:
|
|
77
|
+
cols = list(columns_list)
|
|
78
|
+
else:
|
|
79
|
+
cols = list(columns)
|
|
80
|
+
self._columns: Dict[str, Column] = {}
|
|
81
|
+
for col in cols:
|
|
82
|
+
if col.table is not self:
|
|
83
|
+
object.__setattr__(col, "table", self) # type: ignore[misc]
|
|
84
|
+
self._columns[col.name] = col
|
|
85
|
+
self.c = _ColumnAccessor(self._columns)
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
88
|
+
return f"Table({self.name})"
|
|
89
|
+
|
|
90
|
+
def AS(self, alias: str) -> "Table":
|
|
91
|
+
return Table(self.name, columns_list=self._columns.values(), alias=alias)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def columns(self) -> List[Column]:
|
|
95
|
+
return list(self._columns.values())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def col(name: str, py_type: type) -> Column:
|
|
99
|
+
# Placeholder table; Table will reset table reference on init.
|
|
100
|
+
dummy = Table("__dummy__")
|
|
101
|
+
return Column(name=name, py_type=py_type, table=dummy)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _ColumnAccessor:
|
|
105
|
+
def __init__(self, columns: Dict[str, Column]):
|
|
106
|
+
self._columns = columns
|
|
107
|
+
|
|
108
|
+
def __getattr__(self, item: str) -> Column:
|
|
109
|
+
try:
|
|
110
|
+
return self._columns[item]
|
|
111
|
+
except KeyError as exc:
|
|
112
|
+
raise AttributeError(item) from exc
|
sqlstratum/runner.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""SQLite execution runner."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import Any, Dict, Iterable, Optional
|
|
10
|
+
|
|
11
|
+
from . import ast
|
|
12
|
+
from .compile import compile
|
|
13
|
+
from .hydrate import hydrate_rows
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_LOGGER = logging.getLogger("sqlstratum")
|
|
17
|
+
_DEBUG_TRUE = {"1", "true", "yes"}
|
|
18
|
+
_MAX_PARAM_REPR_LEN = 200
|
|
19
|
+
_MAX_BLOB_PREVIEW = 64
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _env_debug_enabled() -> bool:
|
|
23
|
+
value = os.getenv("SQLSTRATUM_DEBUG", "")
|
|
24
|
+
return value.lower() in _DEBUG_TRUE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _debug_enabled() -> bool:
|
|
28
|
+
return _env_debug_enabled() and _LOGGER.isEnabledFor(logging.DEBUG)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _truncate(value: str, limit: int) -> str:
|
|
32
|
+
if len(value) <= limit:
|
|
33
|
+
return value
|
|
34
|
+
return f"{value[:limit]}...<{len(value) - limit} more>"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _safe_param_repr(value: Any) -> str:
|
|
38
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
39
|
+
data = bytes(value)
|
|
40
|
+
preview = data[:_MAX_BLOB_PREVIEW]
|
|
41
|
+
rep = repr(preview)
|
|
42
|
+
if len(data) > _MAX_BLOB_PREVIEW:
|
|
43
|
+
rep = f"{rep}...<{len(data) - _MAX_BLOB_PREVIEW} more bytes>"
|
|
44
|
+
return rep
|
|
45
|
+
rep = repr(value)
|
|
46
|
+
return _truncate(rep, _MAX_PARAM_REPR_LEN)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _render_params(params: Dict[str, Any]) -> str:
|
|
50
|
+
if not params:
|
|
51
|
+
return "{}"
|
|
52
|
+
items = ", ".join(f"{key}={_safe_param_repr(params[key])}" for key in sorted(params))
|
|
53
|
+
return "{" + items + "}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _debug_log(compiled: ast.Compiled, duration_ms: float) -> None:
|
|
57
|
+
_LOGGER.debug(
|
|
58
|
+
"SQL: %s | params=%s | duration_ms=%.3f",
|
|
59
|
+
compiled.sql,
|
|
60
|
+
_render_params(compiled.params),
|
|
61
|
+
duration_ms,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Runner:
|
|
66
|
+
def __init__(self, connection: sqlite3.Connection):
|
|
67
|
+
self.connection = connection
|
|
68
|
+
self.connection.row_factory = sqlite3.Row
|
|
69
|
+
self._tx_depth = 0
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def connect(cls, path: str) -> "Runner":
|
|
73
|
+
return cls(sqlite3.connect(path))
|
|
74
|
+
|
|
75
|
+
def exec_ddl(self, sql: str) -> None:
|
|
76
|
+
cur = self.connection.cursor()
|
|
77
|
+
cur.execute(sql)
|
|
78
|
+
if self._tx_depth == 0:
|
|
79
|
+
self.connection.commit()
|
|
80
|
+
|
|
81
|
+
def fetch_all(self, query: ast.SelectQuery) -> list[Any]:
|
|
82
|
+
compiled = compile(query)
|
|
83
|
+
log_enabled = _debug_enabled()
|
|
84
|
+
start = time.perf_counter() if log_enabled else 0.0
|
|
85
|
+
cur = self.connection.cursor()
|
|
86
|
+
cur.execute(compiled.sql, compiled.params)
|
|
87
|
+
rows = cur.fetchall()
|
|
88
|
+
if log_enabled:
|
|
89
|
+
_debug_log(compiled, (time.perf_counter() - start) * 1000)
|
|
90
|
+
return hydrate_rows(rows, query.projections, query.hydrate or dict)
|
|
91
|
+
|
|
92
|
+
def fetch_one(self, query: ast.SelectQuery) -> Optional[Any]:
|
|
93
|
+
compiled = compile(query)
|
|
94
|
+
log_enabled = _debug_enabled()
|
|
95
|
+
start = time.perf_counter() if log_enabled else 0.0
|
|
96
|
+
cur = self.connection.cursor()
|
|
97
|
+
cur.execute(compiled.sql, compiled.params)
|
|
98
|
+
row = cur.fetchone()
|
|
99
|
+
if log_enabled:
|
|
100
|
+
_debug_log(compiled, (time.perf_counter() - start) * 1000)
|
|
101
|
+
if row is None:
|
|
102
|
+
return None
|
|
103
|
+
return hydrate_rows([row], query.projections, query.hydrate or dict)[0]
|
|
104
|
+
|
|
105
|
+
def scalar(self, query: ast.SelectQuery) -> Optional[Any]:
|
|
106
|
+
compiled = compile(query)
|
|
107
|
+
log_enabled = _debug_enabled()
|
|
108
|
+
start = time.perf_counter() if log_enabled else 0.0
|
|
109
|
+
cur = self.connection.cursor()
|
|
110
|
+
cur.execute(compiled.sql, compiled.params)
|
|
111
|
+
row = cur.fetchone()
|
|
112
|
+
if log_enabled:
|
|
113
|
+
_debug_log(compiled, (time.perf_counter() - start) * 1000)
|
|
114
|
+
if row is None:
|
|
115
|
+
return None
|
|
116
|
+
return row[0]
|
|
117
|
+
|
|
118
|
+
def execute(self, query: Any) -> ast.ExecutionResult:
|
|
119
|
+
compiled = compile(query)
|
|
120
|
+
log_enabled = _debug_enabled()
|
|
121
|
+
start = time.perf_counter() if log_enabled else 0.0
|
|
122
|
+
cur = self.connection.cursor()
|
|
123
|
+
cur.execute(compiled.sql, compiled.params)
|
|
124
|
+
if self._tx_depth == 0:
|
|
125
|
+
self.connection.commit()
|
|
126
|
+
if log_enabled:
|
|
127
|
+
_debug_log(compiled, (time.perf_counter() - start) * 1000)
|
|
128
|
+
return ast.ExecutionResult(rowcount=cur.rowcount, lastrowid=cur.lastrowid)
|
|
129
|
+
|
|
130
|
+
@contextmanager
|
|
131
|
+
def transaction(self):
|
|
132
|
+
self._tx_depth += 1
|
|
133
|
+
try:
|
|
134
|
+
yield
|
|
135
|
+
except Exception:
|
|
136
|
+
self.connection.rollback()
|
|
137
|
+
raise
|
|
138
|
+
else:
|
|
139
|
+
self.connection.commit()
|
|
140
|
+
finally:
|
|
141
|
+
self._tx_depth -= 1
|
sqlstratum/types.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Type-checker-friendly Protocols for sqlstratum DSL."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Callable, Dict, Optional, Protocol, TYPE_CHECKING, runtime_checkable, TypeVar
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .expr import AliasExpr, BinaryPredicate, LogicalPredicate, NotPredicate, OrderSpec, UnaryPredicate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class Expression(Protocol):
|
|
12
|
+
def AS(self, alias: str) -> "AliasExpr": ...
|
|
13
|
+
|
|
14
|
+
def ASC(self) -> "OrderSpec": ...
|
|
15
|
+
|
|
16
|
+
def DESC(self) -> "OrderSpec": ...
|
|
17
|
+
|
|
18
|
+
def __eq__(self, other: Any) -> "BinaryPredicate": ...
|
|
19
|
+
|
|
20
|
+
def __ne__(self, other: Any) -> "BinaryPredicate": ...
|
|
21
|
+
|
|
22
|
+
def __lt__(self, other: Any) -> "BinaryPredicate": ...
|
|
23
|
+
|
|
24
|
+
def __le__(self, other: Any) -> "BinaryPredicate": ...
|
|
25
|
+
|
|
26
|
+
def __gt__(self, other: Any) -> "BinaryPredicate": ...
|
|
27
|
+
|
|
28
|
+
def __ge__(self, other: Any) -> "BinaryPredicate": ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class Source(Protocol):
|
|
33
|
+
alias: Optional[str]
|
|
34
|
+
c: Any
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
RowMapping = Dict[str, Any]
|
|
38
|
+
T = TypeVar("T")
|
|
39
|
+
Hydrator = Callable[[RowMapping], T]
|
|
40
|
+
HydrationTarget = Callable[[RowMapping], Any] | type[Any] | None
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
Predicate = BinaryPredicate | UnaryPredicate | LogicalPredicate | NotPredicate
|
|
43
|
+
else: # pragma: no cover - typing only
|
|
44
|
+
Predicate = Any
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlstratum
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight, source-first SQL AST + compiler + runner.
|
|
5
|
+
Author-email: Antonio Ognio <aognio@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Antonio Ognio <aognio@gmail.com>
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/aognio/sqlstratum
|
|
29
|
+
Project-URL: Repository, https://github.com/aognio/sqlstratum
|
|
30
|
+
Project-URL: Issues, https://github.com/aognio/sqlstratum/issues
|
|
31
|
+
Keywords: sql,query-builder,sqlite,compiler,ast
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Topic :: Database
|
|
44
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
45
|
+
Requires-Python: >=3.8
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
License-File: LICENSE
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
50
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
51
|
+
Dynamic: license-file
|
|
52
|
+
|
|
53
|
+
# SQLStratum
|
|
54
|
+
|
|
55
|
+
<p align="center">
|
|
56
|
+
<img src="assets/images/SQLStratum-Logo-500x500-transparent.png" alt="SQLStratum logo" />
|
|
57
|
+
</p>
|
|
58
|
+
|
|
59
|
+
SQLStratum is a modern, typed, deterministic SQL query builder and compiler for Python with a
|
|
60
|
+
SQLite runner and a hydration pipeline. It exists to give applications and ORMs a reliable foundation
|
|
61
|
+
layer with composable SQL, predictable parameter binding, and explicit execution boundaries.
|
|
62
|
+
|
|
63
|
+
## Key Features
|
|
64
|
+
- Deterministic compilation: identical AST inputs produce identical SQL + params
|
|
65
|
+
- Typed, composable DSL for SELECT/INSERT/UPDATE/DELETE
|
|
66
|
+
- Safe parameter binding (no raw interpolation)
|
|
67
|
+
- Hydration targets for structured results
|
|
68
|
+
- SQLite-first execution via a small Runner API
|
|
69
|
+
- Testable compiled output and runtime behavior
|
|
70
|
+
|
|
71
|
+
## Non-Goals
|
|
72
|
+
- Not an ORM (no identity map, relationships, lazy loading)
|
|
73
|
+
- Not a migrations/DDL system
|
|
74
|
+
- Not a full database abstraction layer for every backend yet (SQLite first)
|
|
75
|
+
- Not a SQL string templating engine
|
|
76
|
+
|
|
77
|
+
SQLStratum focuses on queries. DDL statements such as `CREATE TABLE` or `ALTER TABLE` are intended to
|
|
78
|
+
live in a complementary library with similar design goals that is currently in the works.
|
|
79
|
+
|
|
80
|
+
## Quickstart
|
|
81
|
+
```python
|
|
82
|
+
import sqlite3
|
|
83
|
+
|
|
84
|
+
from sqlstratum import SELECT, INSERT, Table, col, Runner
|
|
85
|
+
|
|
86
|
+
users = Table(
|
|
87
|
+
"users",
|
|
88
|
+
col("id", int),
|
|
89
|
+
col("email", str),
|
|
90
|
+
col("active", int),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
conn = sqlite3.connect(":memory:")
|
|
94
|
+
runner = Runner(conn)
|
|
95
|
+
runner.exec_ddl("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, active INTEGER)")
|
|
96
|
+
|
|
97
|
+
runner.execute(INSERT(users).VALUES(email="a@b.com", active=1))
|
|
98
|
+
runner.execute(INSERT(users).VALUES(email="c@d.com", active=0))
|
|
99
|
+
|
|
100
|
+
q = (
|
|
101
|
+
SELECT(users.c.id, users.c.email)
|
|
102
|
+
.FROM(users)
|
|
103
|
+
.WHERE(users.c.active.is_true())
|
|
104
|
+
.HYDRATE(dict)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
rows = runner.fetch_all(q)
|
|
108
|
+
print(rows)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Why `Table` objects?
|
|
112
|
+
SQLStratum’s `Table` objects are the schema anchor for the typed, deterministic query builder. They
|
|
113
|
+
provides column metadata and a stable namespace for column access, which enables predictable SQL
|
|
114
|
+
generation and safe parameter binding. They also support explicit aliasing to avoid ambiguous column
|
|
115
|
+
names in joins.
|
|
116
|
+
|
|
117
|
+
## Project Structure
|
|
118
|
+
- AST: immutable query nodes in `sqlstratum/ast.py`
|
|
119
|
+
- Compiler: SQL + params generation in `sqlstratum/compile.py`
|
|
120
|
+
- Runner: SQLite execution and transactions in `sqlstratum/runner.py`
|
|
121
|
+
- Hydration: projection rules and targets in `sqlstratum/hydrate.py`
|
|
122
|
+
|
|
123
|
+
## SQL Debugging
|
|
124
|
+
SQLStratum can log executed SQL statements (compiled SQL + parameters + duration), but logging is
|
|
125
|
+
intentionally gated to avoid noisy output in production. Debug output requires two conditions:
|
|
126
|
+
- Environment variable gate: `SQLSTRATUM_DEBUG` must be truthy (`"1"`, `"true"`, `"yes"`,
|
|
127
|
+
case-insensitive).
|
|
128
|
+
- Logger gate: the `sqlstratum` logger must be DEBUG-enabled.
|
|
129
|
+
|
|
130
|
+
Why it does not work by default: Python logging defaults to WARNING level, so even if
|
|
131
|
+
`SQLSTRATUM_DEBUG=1` is set, DEBUG logs will not appear unless logging is configured.
|
|
132
|
+
|
|
133
|
+
To enable debugging in a development app:
|
|
134
|
+
|
|
135
|
+
Step 1 - set the environment variable:
|
|
136
|
+
```
|
|
137
|
+
SQLSTRATUM_DEBUG=1
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Step 2 - configure logging early in the app:
|
|
141
|
+
```python
|
|
142
|
+
import logging
|
|
143
|
+
|
|
144
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
145
|
+
# or
|
|
146
|
+
logging.getLogger("sqlstratum").setLevel(logging.DEBUG)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Output looks like:
|
|
150
|
+
```
|
|
151
|
+
SQL: <compiled sql> | params={<sorted params>} | duration_ms=<...>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Architectural intent: logging happens at the Runner boundary (after execution). AST building and
|
|
155
|
+
compilation remain deterministic and side-effect free, preserving separation of concerns.
|
|
156
|
+
|
|
157
|
+
## Logo Inspiration
|
|
158
|
+
|
|
159
|
+
Vinicunca (Rainbow Mountain) in Peru’s Cusco Region — a high-altitude day hike from
|
|
160
|
+
Cusco at roughly 5,036 m (16,500 ft). See [Vinicunca](https://en.wikipedia.org/wiki/Vinicunca) for
|
|
161
|
+
background.
|
|
162
|
+
|
|
163
|
+
## Versioning / Roadmap
|
|
164
|
+
Current version: `0.1.0`.
|
|
165
|
+
Design notes and current limitations are tracked in `NOTES.md`. Roadmap planning is intentionally
|
|
166
|
+
minimal at this stage and will evolve with real usage.
|
|
167
|
+
|
|
168
|
+
## Authorship
|
|
169
|
+
[Antonio Ognio](https://github.com/aognio/) is the maintainer and author of SQLStratum. ChatGPT is used for brainstorming,
|
|
170
|
+
architectural thinking, documentation drafting, and project management advisory. Codex (CLI/agentic
|
|
171
|
+
coding) is used to implement many code changes under Antonio's direction and review. The maintainer
|
|
172
|
+
reviews and curates changes; AI tools are assistants, not owners, and accountability remains with the
|
|
173
|
+
maintainer.
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
MIT License.
|
|
177
|
+
|
|
178
|
+
## Contributing
|
|
179
|
+
PRs are welcome. Please read `CONTRIBUTING.md` for the workflow and expectations.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
sqlstratum/__init__.py,sha256=Qa9boJoOFjo429057MHBm-X0Qf6jHLC28o21JIROaXg,652
|
|
2
|
+
sqlstratum/ast.py,sha256=zbycfm_3zdQCq-g-XCZhS20QTNCUrr1I7hlZ0-4xoeQ,2027
|
|
3
|
+
sqlstratum/compile.py,sha256=_d_q0mGQT7LKrtveTejTqgGCt63TewLGdgc0z4bcTzI,6371
|
|
4
|
+
sqlstratum/dsl.py,sha256=stW_ByQuzGL9tdfm3wIQ8nhAsI-3G9sjFVNojVc8Tek,4478
|
|
5
|
+
sqlstratum/expr.py,sha256=NfqF1HKgbZyae4fMOq9UU4zDIhbmwVyZLVx8HGT2e5c,2629
|
|
6
|
+
sqlstratum/hydrate.py,sha256=giaeAk6otaXIKvDLHZ7oht4MF8OAX9UgijAW2gzpG_Q,1532
|
|
7
|
+
sqlstratum/meta.py,sha256=ZjM_mO-nQgPvChSIByTiyab1pgWmgVJ1Y2jafvKisc8,3953
|
|
8
|
+
sqlstratum/runner.py,sha256=6djSwHntK7T87jI_tkLVDzkfBRa1f-3mjRvIwVPJnuU,4439
|
|
9
|
+
sqlstratum/types.py,sha256=QRfLxf2awVtAAf8aZk8DFasBJTfNKzuE4Gfb3RjUnEc,1251
|
|
10
|
+
sqlstratum-0.1.0.dist-info/licenses/LICENSE,sha256=fbtpXCPZdyN1PZtmPY6pQGqQ6uK7ceT_xcAuHvLVeNw,1089
|
|
11
|
+
sqlstratum-0.1.0.dist-info/METADATA,sha256=Xzk70uyjsg_LONlP37XN0Hyau5QWkhtimz9o9wYhyuI,7074
|
|
12
|
+
sqlstratum-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
13
|
+
sqlstratum-0.1.0.dist-info/top_level.txt,sha256=Hi7z6fGPj8r4mv7p8GFS9afMvXJCmcH_crD_yS6lnNg,11
|
|
14
|
+
sqlstratum-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Antonio Ognio <aognio@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sqlstratum
|