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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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