inputlayer-client-dev 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.
- inputlayer/__init__.py +151 -0
- inputlayer/_ast.py +133 -0
- inputlayer/_naming.py +44 -0
- inputlayer/_protocol.py +225 -0
- inputlayer/_proxy.py +244 -0
- inputlayer/aggregations.py +126 -0
- inputlayer/auth.py +70 -0
- inputlayer/client.py +178 -0
- inputlayer/client_sync.py +231 -0
- inputlayer/compiler.py +672 -0
- inputlayer/connection.py +314 -0
- inputlayer/derived.py +141 -0
- inputlayer/exceptions.py +76 -0
- inputlayer/functions.py +285 -0
- inputlayer/index.py +40 -0
- inputlayer/knowledge_graph.py +589 -0
- inputlayer/migrations/__init__.py +51 -0
- inputlayer/migrations/autodetector.py +120 -0
- inputlayer/migrations/cli.py +254 -0
- inputlayer/migrations/executor.py +95 -0
- inputlayer/migrations/loader.py +91 -0
- inputlayer/migrations/operations.py +298 -0
- inputlayer/migrations/recorder.py +44 -0
- inputlayer/migrations/state.py +107 -0
- inputlayer/migrations/writer.py +183 -0
- inputlayer/notifications.py +83 -0
- inputlayer/py.typed +0 -0
- inputlayer/relation.py +102 -0
- inputlayer/result.py +89 -0
- inputlayer/session.py +75 -0
- inputlayer/types.py +192 -0
- inputlayer_client_dev-0.1.0.dist-info/METADATA +364 -0
- inputlayer_client_dev-0.1.0.dist-info/RECORD +35 -0
- inputlayer_client_dev-0.1.0.dist-info/WHEEL +4 -0
- inputlayer_client_dev-0.1.0.dist-info/entry_points.txt +2 -0
inputlayer/compiler.py
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
"""Compiler: Python objects and AST nodes → Datalog text.
|
|
2
|
+
|
|
3
|
+
This is the core compilation layer. Every method is pure (no I/O),
|
|
4
|
+
taking Python objects and returning Datalog strings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Sequence
|
|
10
|
+
|
|
11
|
+
from inputlayer._ast import (
|
|
12
|
+
AggExpr,
|
|
13
|
+
And,
|
|
14
|
+
Arithmetic,
|
|
15
|
+
BoolExpr,
|
|
16
|
+
Column as AstColumn,
|
|
17
|
+
Comparison,
|
|
18
|
+
Expr,
|
|
19
|
+
FuncCall,
|
|
20
|
+
InExpr,
|
|
21
|
+
Literal,
|
|
22
|
+
MatchExpr,
|
|
23
|
+
NegatedIn,
|
|
24
|
+
Not,
|
|
25
|
+
Or,
|
|
26
|
+
OrderedColumn,
|
|
27
|
+
)
|
|
28
|
+
from inputlayer._naming import column_to_variable
|
|
29
|
+
from inputlayer.types import Timestamp, Vector, VectorInt8, python_type_to_datalog
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from inputlayer.relation import Relation
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Value compilation ─────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compile_value(value: Any) -> str:
|
|
39
|
+
"""Compile a Python value to its Datalog literal representation."""
|
|
40
|
+
if value is None:
|
|
41
|
+
return "null"
|
|
42
|
+
if isinstance(value, bool):
|
|
43
|
+
return "true" if value else "false"
|
|
44
|
+
if isinstance(value, int):
|
|
45
|
+
return str(value)
|
|
46
|
+
if isinstance(value, float):
|
|
47
|
+
return repr(value)
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
50
|
+
return f'"{escaped}"'
|
|
51
|
+
if isinstance(value, (list, tuple)):
|
|
52
|
+
# Vector literal: [1.0, 2.0, 3.0]
|
|
53
|
+
inner = ", ".join(compile_value(v) for v in value)
|
|
54
|
+
return f"[{inner}]"
|
|
55
|
+
if isinstance(value, Timestamp):
|
|
56
|
+
return str(int(value))
|
|
57
|
+
raise TypeError(f"Cannot compile value of type {type(value).__name__}: {value!r}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── Expression compilation ────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _VarEnv:
|
|
64
|
+
"""Variable environment for tracking column→variable mappings with union-find.
|
|
65
|
+
|
|
66
|
+
Ensures that join conditions like e.department == d.name produce a single
|
|
67
|
+
shared Datalog variable.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self._map: dict[str, str] = {} # "relation.col" or "alias.col" → Var
|
|
72
|
+
self._counter = 0
|
|
73
|
+
self._parent: dict[str, str] = {} # Union-find parent
|
|
74
|
+
|
|
75
|
+
def _find(self, key: str) -> str:
|
|
76
|
+
"""Find root of union-find set."""
|
|
77
|
+
while self._parent.get(key, key) != key:
|
|
78
|
+
self._parent[key] = self._parent.get(self._parent[key], self._parent[key])
|
|
79
|
+
key = self._parent[key]
|
|
80
|
+
return key
|
|
81
|
+
|
|
82
|
+
def _union(self, a: str, b: str) -> None:
|
|
83
|
+
"""Merge two variable sets."""
|
|
84
|
+
ra, rb = self._find(a), self._find(b)
|
|
85
|
+
if ra != rb:
|
|
86
|
+
self._parent[rb] = ra
|
|
87
|
+
|
|
88
|
+
def get_var(self, col: AstColumn) -> str:
|
|
89
|
+
"""Get or create a Datalog variable for a column."""
|
|
90
|
+
key = f"{col.ref_alias or col.relation}.{col.name}"
|
|
91
|
+
root = self._find(key)
|
|
92
|
+
if root in self._map:
|
|
93
|
+
return self._map[root]
|
|
94
|
+
var = column_to_variable(col.name)
|
|
95
|
+
# If this var name is already used by a different root, disambiguate
|
|
96
|
+
used_vars = set(self._map.values())
|
|
97
|
+
if var in used_vars:
|
|
98
|
+
self._counter += 1
|
|
99
|
+
var = f"{var}_{self._counter}"
|
|
100
|
+
self._map[root] = var
|
|
101
|
+
return var
|
|
102
|
+
|
|
103
|
+
def unify(self, col_a: AstColumn, col_b: AstColumn) -> str:
|
|
104
|
+
"""Unify two columns to the same Datalog variable (join condition)."""
|
|
105
|
+
key_a = f"{col_a.ref_alias or col_a.relation}.{col_a.name}"
|
|
106
|
+
key_b = f"{col_b.ref_alias or col_b.relation}.{col_b.name}"
|
|
107
|
+
self._union(key_a, key_b)
|
|
108
|
+
root = self._find(key_a)
|
|
109
|
+
if root in self._map:
|
|
110
|
+
return self._map[root]
|
|
111
|
+
var = column_to_variable(col_a.name)
|
|
112
|
+
used_vars = set(self._map.values())
|
|
113
|
+
if var in used_vars:
|
|
114
|
+
self._counter += 1
|
|
115
|
+
var = f"{var}_{self._counter}"
|
|
116
|
+
self._map[root] = var
|
|
117
|
+
return var
|
|
118
|
+
|
|
119
|
+
def lookup(self, col: AstColumn) -> str | None:
|
|
120
|
+
"""Look up existing variable for a column without creating one."""
|
|
121
|
+
key = f"{col.ref_alias or col.relation}.{col.name}"
|
|
122
|
+
root = self._find(key)
|
|
123
|
+
return self._map.get(root)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def compile_expr(expr: Expr, env: _VarEnv) -> str:
|
|
127
|
+
"""Compile an Expr AST node to Datalog text."""
|
|
128
|
+
if isinstance(expr, AstColumn):
|
|
129
|
+
return env.get_var(expr)
|
|
130
|
+
if isinstance(expr, Literal):
|
|
131
|
+
return compile_value(expr.value)
|
|
132
|
+
if isinstance(expr, Arithmetic):
|
|
133
|
+
left = compile_expr(expr.left, env)
|
|
134
|
+
right = compile_expr(expr.right, env)
|
|
135
|
+
return f"{left} {expr.op} {right}"
|
|
136
|
+
if isinstance(expr, FuncCall):
|
|
137
|
+
args = ", ".join(compile_expr(a, env) for a in expr.args)
|
|
138
|
+
return f"{expr.name}({args})"
|
|
139
|
+
if isinstance(expr, OrderedColumn):
|
|
140
|
+
var = compile_expr(expr.column, env)
|
|
141
|
+
suffix = ":desc" if expr.descending else ":asc"
|
|
142
|
+
return f"{var}{suffix}"
|
|
143
|
+
if isinstance(expr, AggExpr):
|
|
144
|
+
return _compile_agg_expr(expr, env)
|
|
145
|
+
raise TypeError(f"Cannot compile expression: {expr!r}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _compile_agg_expr(agg: AggExpr, env: _VarEnv) -> str:
|
|
149
|
+
"""Compile an aggregation expression to Datalog syntax."""
|
|
150
|
+
func = agg.func
|
|
151
|
+
parts: list[str] = []
|
|
152
|
+
|
|
153
|
+
# Params first (k, threshold, radius, etc.)
|
|
154
|
+
for p in agg.params:
|
|
155
|
+
parts.append(compile_value(p))
|
|
156
|
+
|
|
157
|
+
# Passthrough columns
|
|
158
|
+
for pt in agg.passthrough:
|
|
159
|
+
parts.append(compile_expr(pt, env))
|
|
160
|
+
|
|
161
|
+
# The aggregated column (for top_k this is the ordering column)
|
|
162
|
+
if agg.order_column is not None:
|
|
163
|
+
order_var = compile_expr(agg.order_column, env)
|
|
164
|
+
suffix = ":desc" if agg.desc else ":asc"
|
|
165
|
+
parts.append(f"{order_var}{suffix}")
|
|
166
|
+
elif agg.column is not None:
|
|
167
|
+
parts.append(compile_expr(agg.column, env))
|
|
168
|
+
|
|
169
|
+
inner = ", ".join(parts)
|
|
170
|
+
return f"{func}<{inner}>"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── Boolean expression compilation ───────────────────────────────────
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def compile_bool_expr(expr: BoolExpr, env: _VarEnv) -> list[str]:
|
|
177
|
+
"""Compile a BoolExpr to a list of Datalog body literals.
|
|
178
|
+
|
|
179
|
+
AND → multiple literals; OR → raises (must be handled by caller splitting).
|
|
180
|
+
Returns a list of Datalog body atoms/conditions joined by comma in the caller.
|
|
181
|
+
"""
|
|
182
|
+
if isinstance(expr, Comparison):
|
|
183
|
+
return [_compile_comparison(expr, env)]
|
|
184
|
+
if isinstance(expr, And):
|
|
185
|
+
return compile_bool_expr(expr.left, env) + compile_bool_expr(expr.right, env)
|
|
186
|
+
if isinstance(expr, Or):
|
|
187
|
+
raise ValueError(
|
|
188
|
+
"OR conditions require query splitting. "
|
|
189
|
+
"Use compile_or_branches() instead."
|
|
190
|
+
)
|
|
191
|
+
if isinstance(expr, Not):
|
|
192
|
+
inner_parts = compile_bool_expr(expr.operand, env)
|
|
193
|
+
return [f"!({', '.join(inner_parts)})"]
|
|
194
|
+
if isinstance(expr, InExpr):
|
|
195
|
+
return [_compile_in(expr, env, negated=False)]
|
|
196
|
+
if isinstance(expr, NegatedIn):
|
|
197
|
+
return [_compile_in(expr, env, negated=True)]
|
|
198
|
+
if isinstance(expr, MatchExpr):
|
|
199
|
+
return [_compile_match(expr, env)]
|
|
200
|
+
raise TypeError(f"Cannot compile boolean expression: {expr!r}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _compile_comparison(comp: Comparison, env: _VarEnv) -> str:
|
|
204
|
+
"""Compile a single comparison to Datalog."""
|
|
205
|
+
# Check for join condition: Column == Column → unify variables
|
|
206
|
+
if (
|
|
207
|
+
comp.op == "="
|
|
208
|
+
and isinstance(comp.left, AstColumn)
|
|
209
|
+
and isinstance(comp.right, AstColumn)
|
|
210
|
+
):
|
|
211
|
+
env.unify(comp.left, comp.right)
|
|
212
|
+
return "" # Join expressed through shared variable, no explicit condition
|
|
213
|
+
left = compile_expr(comp.left, env)
|
|
214
|
+
right = compile_expr(comp.right, env)
|
|
215
|
+
return f"{left} {comp.op} {right}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _compile_in(expr: InExpr | NegatedIn, env: _VarEnv, *, negated: bool) -> str:
|
|
219
|
+
"""Compile in_() / negated in_() to Datalog."""
|
|
220
|
+
src_var = compile_expr(expr.column, env)
|
|
221
|
+
assert isinstance(expr.target_column, AstColumn)
|
|
222
|
+
tgt_col = expr.target_column
|
|
223
|
+
# Build a body atom for the target relation with the column bound
|
|
224
|
+
tgt_var = env.get_var(tgt_col)
|
|
225
|
+
# Force unification: src_var should equal tgt_var
|
|
226
|
+
# This is expressed by using the same variable in both positions
|
|
227
|
+
env.unify(expr.column, expr.target_column) # type: ignore[arg-type]
|
|
228
|
+
# Re-fetch after unification
|
|
229
|
+
tgt_var = env.get_var(tgt_col)
|
|
230
|
+
prefix = "!" if negated else ""
|
|
231
|
+
# We need to produce the target relation atom
|
|
232
|
+
return f"{prefix}{tgt_col.relation}(..., {tgt_var}, ...)"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _compile_match(match: MatchExpr, env: _VarEnv) -> str:
|
|
236
|
+
"""Compile a MatchExpr to a Datalog body atom."""
|
|
237
|
+
parts = []
|
|
238
|
+
for col_name, source_expr in match.bindings.items():
|
|
239
|
+
var = compile_expr(source_expr, env)
|
|
240
|
+
parts.append(var)
|
|
241
|
+
atom_inner = ", ".join(parts)
|
|
242
|
+
prefix = "!" if match.negated else ""
|
|
243
|
+
return f"{prefix}{match.relation}({atom_inner})"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def compile_or_branches(expr: BoolExpr, env: _VarEnv) -> list[list[str]]:
|
|
247
|
+
"""Split OR conditions into separate branches, each a list of body literals."""
|
|
248
|
+
if isinstance(expr, Or):
|
|
249
|
+
left_branches = compile_or_branches(expr.left, env)
|
|
250
|
+
right_branches = compile_or_branches(expr.right, env)
|
|
251
|
+
return left_branches + right_branches
|
|
252
|
+
return [compile_bool_expr(expr, env)]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── Schema compilation ────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def compile_schema(relation_cls: type[Relation]) -> str:
|
|
259
|
+
"""Compile a Relation class to a schema definition statement.
|
|
260
|
+
|
|
261
|
+
Example: +employee(id: int, name: string, salary: float)
|
|
262
|
+
"""
|
|
263
|
+
from inputlayer.relation import Relation
|
|
264
|
+
|
|
265
|
+
name = Relation._resolve_name(relation_cls)
|
|
266
|
+
columns = Relation._get_columns(relation_cls)
|
|
267
|
+
col_types = Relation._get_column_types(relation_cls)
|
|
268
|
+
|
|
269
|
+
parts = []
|
|
270
|
+
for col in columns:
|
|
271
|
+
tp = col_types[col]
|
|
272
|
+
dl_type = python_type_to_datalog(tp)
|
|
273
|
+
parts.append(f"{col}: {dl_type}")
|
|
274
|
+
|
|
275
|
+
return f"+{name}({', '.join(parts)})"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ── Insert compilation ────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def compile_insert(fact: Relation, *, persistent: bool = True) -> str:
|
|
282
|
+
"""Compile a single Relation instance to an insert statement.
|
|
283
|
+
|
|
284
|
+
persistent=True → +employee(1, "Alice", ...)
|
|
285
|
+
persistent=False → employee(1, "Alice", ...) (session fact)
|
|
286
|
+
"""
|
|
287
|
+
from inputlayer.relation import Relation
|
|
288
|
+
|
|
289
|
+
name = Relation._resolve_name(type(fact))
|
|
290
|
+
columns = Relation._get_columns(type(fact))
|
|
291
|
+
values = [compile_value(getattr(fact, col)) for col in columns]
|
|
292
|
+
prefix = "+" if persistent else ""
|
|
293
|
+
return f"{prefix}{name}({', '.join(values)})"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def compile_bulk_insert(
|
|
297
|
+
relation_cls: type[Relation],
|
|
298
|
+
facts: Sequence[Relation],
|
|
299
|
+
*,
|
|
300
|
+
persistent: bool = True,
|
|
301
|
+
) -> str:
|
|
302
|
+
"""Compile a list of facts to a bulk insert statement.
|
|
303
|
+
|
|
304
|
+
+employee[(1, "Alice", ...), (2, "Bob", ...)]
|
|
305
|
+
"""
|
|
306
|
+
from inputlayer.relation import Relation
|
|
307
|
+
|
|
308
|
+
name = Relation._resolve_name(relation_cls)
|
|
309
|
+
columns = Relation._get_columns(relation_cls)
|
|
310
|
+
tuples = []
|
|
311
|
+
for fact in facts:
|
|
312
|
+
values = [compile_value(getattr(fact, col)) for col in columns]
|
|
313
|
+
tuples.append(f"({', '.join(values)})")
|
|
314
|
+
prefix = "+" if persistent else ""
|
|
315
|
+
return f"{prefix}{name}[{', '.join(tuples)}]"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ── Delete compilation ────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def compile_delete(fact: Relation) -> str:
|
|
322
|
+
"""Compile a single fact deletion.
|
|
323
|
+
|
|
324
|
+
-employee(1, "Alice", ...)
|
|
325
|
+
"""
|
|
326
|
+
from inputlayer.relation import Relation
|
|
327
|
+
|
|
328
|
+
name = Relation._resolve_name(type(fact))
|
|
329
|
+
columns = Relation._get_columns(type(fact))
|
|
330
|
+
values = [compile_value(getattr(fact, col)) for col in columns]
|
|
331
|
+
return f"-{name}({', '.join(values)})"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def compile_conditional_delete(
|
|
335
|
+
relation_cls: type[Relation],
|
|
336
|
+
condition: BoolExpr,
|
|
337
|
+
) -> str:
|
|
338
|
+
"""Compile a conditional delete.
|
|
339
|
+
|
|
340
|
+
-employee(X0, X1, X2, X3) <- employee(X0, X1, X2, X3), X2 = "sales"
|
|
341
|
+
"""
|
|
342
|
+
from inputlayer.relation import Relation
|
|
343
|
+
|
|
344
|
+
name = Relation._resolve_name(relation_cls)
|
|
345
|
+
columns = Relation._get_columns(relation_cls)
|
|
346
|
+
|
|
347
|
+
# Generate X0, X1, ... variables for each column
|
|
348
|
+
vars_ = [f"X{i}" for i in range(len(columns))]
|
|
349
|
+
head = f"-{name}({', '.join(vars_)})"
|
|
350
|
+
|
|
351
|
+
# Build a variable environment that maps columns to X0, X1, ...
|
|
352
|
+
env = _VarEnv()
|
|
353
|
+
for i, col in enumerate(columns):
|
|
354
|
+
col_ast = AstColumn(name, col)
|
|
355
|
+
key = f"{name}.{col}"
|
|
356
|
+
env._map[key] = vars_[i]
|
|
357
|
+
|
|
358
|
+
# Auto-join: include the target relation in the body
|
|
359
|
+
body_rel = f"{name}({', '.join(vars_)})"
|
|
360
|
+
|
|
361
|
+
# Compile the condition
|
|
362
|
+
cond_parts = compile_bool_expr(condition, env)
|
|
363
|
+
cond_parts = [p for p in cond_parts if p] # Remove empty strings from join unification
|
|
364
|
+
|
|
365
|
+
body_parts = [body_rel] + cond_parts
|
|
366
|
+
return f"{head} <- {', '.join(body_parts)}"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ── Query compilation ─────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def compile_query(
|
|
373
|
+
*select: type[Relation] | Expr,
|
|
374
|
+
relations: list[type[Relation] | Any] | None = None,
|
|
375
|
+
on_condition: BoolExpr | None = None,
|
|
376
|
+
where_condition: BoolExpr | None = None,
|
|
377
|
+
order_by: Expr | None = None,
|
|
378
|
+
limit: int | None = None,
|
|
379
|
+
offset: int | None = None,
|
|
380
|
+
computed: dict[str, Expr] | None = None,
|
|
381
|
+
) -> str | list[str]:
|
|
382
|
+
"""Compile a query to Datalog.
|
|
383
|
+
|
|
384
|
+
Returns a single string, or a list of strings if OR conditions require splitting.
|
|
385
|
+
"""
|
|
386
|
+
from inputlayer.relation import Relation
|
|
387
|
+
from inputlayer._proxy import RelationRef
|
|
388
|
+
|
|
389
|
+
env = _VarEnv()
|
|
390
|
+
|
|
391
|
+
# Determine which relations are involved
|
|
392
|
+
all_relations: list[tuple[str, type[Relation], str | None]] = [] # (name, cls, alias)
|
|
393
|
+
|
|
394
|
+
if relations:
|
|
395
|
+
for r in relations:
|
|
396
|
+
if isinstance(r, RelationRef):
|
|
397
|
+
all_relations.append((r.relation_name, r.relation_cls, r.alias))
|
|
398
|
+
elif isinstance(r, type) and issubclass(r, Relation):
|
|
399
|
+
all_relations.append((Relation._resolve_name(r), r, None))
|
|
400
|
+
|
|
401
|
+
# Process join conditions first to set up unification
|
|
402
|
+
if on_condition:
|
|
403
|
+
_process_join_condition(on_condition, env)
|
|
404
|
+
|
|
405
|
+
# Process where conditions
|
|
406
|
+
where_parts: list[str] = []
|
|
407
|
+
or_branches: list[list[str]] | None = None
|
|
408
|
+
if where_condition:
|
|
409
|
+
if _has_or(where_condition):
|
|
410
|
+
or_branches = compile_or_branches(where_condition, env)
|
|
411
|
+
else:
|
|
412
|
+
where_parts = compile_bool_expr(where_condition, env)
|
|
413
|
+
where_parts = [p for p in where_parts if p]
|
|
414
|
+
|
|
415
|
+
# Build the head (select) and body
|
|
416
|
+
has_agg = any(isinstance(s, AggExpr) for s in select)
|
|
417
|
+
computed = computed or {}
|
|
418
|
+
has_computed_agg = any(isinstance(v, AggExpr) for v in computed.values())
|
|
419
|
+
|
|
420
|
+
if has_agg or has_computed_agg:
|
|
421
|
+
return _compile_agg_query(
|
|
422
|
+
select, env, all_relations, where_parts, or_branches,
|
|
423
|
+
order_by, limit, offset, computed,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Simple query (no aggregations)
|
|
427
|
+
head_parts: list[str] = []
|
|
428
|
+
body_atoms: list[str] = []
|
|
429
|
+
|
|
430
|
+
# Collect selected columns per relation
|
|
431
|
+
selected_by_rel: dict[str, list[AstColumn]] = {}
|
|
432
|
+
full_relations: list[tuple[str, type[Relation], str | None]] = []
|
|
433
|
+
|
|
434
|
+
for s in select:
|
|
435
|
+
if isinstance(s, type) and issubclass(s, Relation):
|
|
436
|
+
rn = Relation._resolve_name(s)
|
|
437
|
+
full_relations.append((rn, s, None))
|
|
438
|
+
elif isinstance(s, AstColumn):
|
|
439
|
+
key = s.ref_alias or s.relation
|
|
440
|
+
selected_by_rel.setdefault(key, []).append(s)
|
|
441
|
+
|
|
442
|
+
# If selecting full relations, select all their columns
|
|
443
|
+
if full_relations:
|
|
444
|
+
for rn, cls, alias in full_relations:
|
|
445
|
+
cols = Relation._get_columns(cls)
|
|
446
|
+
for col in cols:
|
|
447
|
+
ast_col = AstColumn(rn, col, alias)
|
|
448
|
+
var = env.get_var(ast_col)
|
|
449
|
+
head_parts.append(var)
|
|
450
|
+
# Also ensure relation is in the body
|
|
451
|
+
if not any(r[0] == rn and r[2] == alias for r in all_relations):
|
|
452
|
+
all_relations.append((rn, cls, alias))
|
|
453
|
+
|
|
454
|
+
# Add individual selected columns to head
|
|
455
|
+
for s in select:
|
|
456
|
+
if isinstance(s, AstColumn):
|
|
457
|
+
var = env.get_var(s)
|
|
458
|
+
head_parts.append(var)
|
|
459
|
+
|
|
460
|
+
# Add computed columns to head
|
|
461
|
+
for alias_name, expr in computed.items():
|
|
462
|
+
compiled = compile_expr(expr, env)
|
|
463
|
+
head_parts.append(compiled)
|
|
464
|
+
|
|
465
|
+
# Handle order_by
|
|
466
|
+
if order_by is not None:
|
|
467
|
+
# Find and replace the matching head variable with ordered version
|
|
468
|
+
if isinstance(order_by, OrderedColumn):
|
|
469
|
+
order_var = compile_expr(order_by.column, env)
|
|
470
|
+
suffix = ":desc" if order_by.descending else ":asc"
|
|
471
|
+
# Replace in head_parts
|
|
472
|
+
for i, hp in enumerate(head_parts):
|
|
473
|
+
if hp == order_var:
|
|
474
|
+
head_parts[i] = f"{order_var}{suffix}"
|
|
475
|
+
break
|
|
476
|
+
elif isinstance(order_by, AstColumn):
|
|
477
|
+
order_var = env.get_var(order_by)
|
|
478
|
+
suffix = ":asc"
|
|
479
|
+
for i, hp in enumerate(head_parts):
|
|
480
|
+
if hp == order_var:
|
|
481
|
+
head_parts[i] = f"{order_var}{suffix}"
|
|
482
|
+
break
|
|
483
|
+
|
|
484
|
+
# Build body atoms for each relation
|
|
485
|
+
for rn, cls, alias in all_relations:
|
|
486
|
+
cols = Relation._get_columns(cls)
|
|
487
|
+
atom_parts = []
|
|
488
|
+
for col in cols:
|
|
489
|
+
ast_col = AstColumn(rn, col, alias)
|
|
490
|
+
var = env.lookup(ast_col)
|
|
491
|
+
if var is not None:
|
|
492
|
+
atom_parts.append(var)
|
|
493
|
+
else:
|
|
494
|
+
atom_parts.append("_")
|
|
495
|
+
body_atoms.append(f"{rn}({', '.join(atom_parts)})")
|
|
496
|
+
|
|
497
|
+
# Combine body
|
|
498
|
+
all_body = body_atoms + where_parts
|
|
499
|
+
if limit is not None:
|
|
500
|
+
if offset is not None:
|
|
501
|
+
all_body.append(f"limit({limit}, {offset})")
|
|
502
|
+
else:
|
|
503
|
+
all_body.append(f"limit({limit})")
|
|
504
|
+
|
|
505
|
+
head_str = ", ".join(head_parts)
|
|
506
|
+
|
|
507
|
+
if or_branches is not None:
|
|
508
|
+
# Multiple queries for OR
|
|
509
|
+
queries = []
|
|
510
|
+
for branch_parts in or_branches:
|
|
511
|
+
branch_parts = [p for p in branch_parts if p]
|
|
512
|
+
branch_body = body_atoms + branch_parts
|
|
513
|
+
if limit is not None:
|
|
514
|
+
if offset is not None:
|
|
515
|
+
branch_body.append(f"limit({limit}, {offset})")
|
|
516
|
+
else:
|
|
517
|
+
branch_body.append(f"limit({limit})")
|
|
518
|
+
queries.append(f"?{head_str} <- {', '.join(branch_body)}")
|
|
519
|
+
return queries
|
|
520
|
+
|
|
521
|
+
if all_body:
|
|
522
|
+
return f"?{head_str} <- {', '.join(all_body)}"
|
|
523
|
+
return f"?{head_str}"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _process_join_condition(condition: BoolExpr, env: _VarEnv) -> None:
|
|
527
|
+
"""Process join conditions to set up variable unification."""
|
|
528
|
+
if isinstance(condition, Comparison) and condition.op == "=":
|
|
529
|
+
if isinstance(condition.left, AstColumn) and isinstance(condition.right, AstColumn):
|
|
530
|
+
env.unify(condition.left, condition.right)
|
|
531
|
+
return
|
|
532
|
+
if isinstance(condition, And):
|
|
533
|
+
_process_join_condition(condition.left, env)
|
|
534
|
+
_process_join_condition(condition.right, env)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _has_or(expr: BoolExpr) -> bool:
|
|
538
|
+
"""Check if expression contains any OR nodes."""
|
|
539
|
+
if isinstance(expr, Or):
|
|
540
|
+
return True
|
|
541
|
+
if isinstance(expr, And):
|
|
542
|
+
return _has_or(expr.left) or _has_or(expr.right)
|
|
543
|
+
if isinstance(expr, Not):
|
|
544
|
+
return _has_or(expr.operand)
|
|
545
|
+
return False
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _compile_agg_query(
|
|
549
|
+
select: tuple,
|
|
550
|
+
env: _VarEnv,
|
|
551
|
+
all_relations: list[tuple[str, type, str | None]],
|
|
552
|
+
where_parts: list[str],
|
|
553
|
+
or_branches: list[list[str]] | None,
|
|
554
|
+
order_by: Expr | None,
|
|
555
|
+
limit: int | None,
|
|
556
|
+
offset: int | None,
|
|
557
|
+
computed: dict[str, Expr],
|
|
558
|
+
) -> str:
|
|
559
|
+
"""Compile a query with aggregation."""
|
|
560
|
+
from inputlayer.relation import Relation
|
|
561
|
+
|
|
562
|
+
head_parts: list[str] = []
|
|
563
|
+
agg_parts: list[str] = []
|
|
564
|
+
|
|
565
|
+
# Separate grouping keys from aggregations
|
|
566
|
+
for s in select:
|
|
567
|
+
if isinstance(s, AggExpr):
|
|
568
|
+
agg_parts.append(compile_expr(s, env))
|
|
569
|
+
elif isinstance(s, AstColumn):
|
|
570
|
+
head_parts.append(env.get_var(s))
|
|
571
|
+
elif isinstance(s, type) and issubclass(s, Relation):
|
|
572
|
+
rn = Relation._resolve_name(s)
|
|
573
|
+
cols = Relation._get_columns(s)
|
|
574
|
+
for col in cols:
|
|
575
|
+
ast_col = AstColumn(rn, col)
|
|
576
|
+
head_parts.append(env.get_var(ast_col))
|
|
577
|
+
|
|
578
|
+
for alias_name, expr in computed.items():
|
|
579
|
+
if isinstance(expr, AggExpr):
|
|
580
|
+
agg_parts.append(compile_expr(expr, env))
|
|
581
|
+
else:
|
|
582
|
+
head_parts.append(compile_expr(expr, env))
|
|
583
|
+
|
|
584
|
+
# Build body
|
|
585
|
+
body_atoms: list[str] = []
|
|
586
|
+
for rn, cls, alias in all_relations:
|
|
587
|
+
cols = Relation._get_columns(cls)
|
|
588
|
+
atom_parts = []
|
|
589
|
+
for col in cols:
|
|
590
|
+
ast_col = AstColumn(rn, col, alias)
|
|
591
|
+
var = env.lookup(ast_col)
|
|
592
|
+
if var is not None:
|
|
593
|
+
atom_parts.append(var)
|
|
594
|
+
else:
|
|
595
|
+
atom_parts.append("_")
|
|
596
|
+
body_atoms.append(f"{rn}({', '.join(atom_parts)})")
|
|
597
|
+
|
|
598
|
+
all_body = body_atoms + where_parts
|
|
599
|
+
if limit is not None:
|
|
600
|
+
if offset is not None:
|
|
601
|
+
all_body.append(f"limit({limit}, {offset})")
|
|
602
|
+
else:
|
|
603
|
+
all_body.append(f"limit({limit})")
|
|
604
|
+
|
|
605
|
+
all_head = head_parts + agg_parts
|
|
606
|
+
head_str = ", ".join(all_head)
|
|
607
|
+
|
|
608
|
+
if all_body:
|
|
609
|
+
return f"?{head_str} <- {', '.join(all_body)}"
|
|
610
|
+
return f"?{head_str}"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# ── Rule compilation ──────────────────────────────────────────────────
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def compile_rule(
|
|
617
|
+
head_name: str,
|
|
618
|
+
head_columns: list[str],
|
|
619
|
+
select_map: dict[str, Expr],
|
|
620
|
+
body_relations: list[tuple[str, type[Relation], str | None]],
|
|
621
|
+
condition: BoolExpr | None = None,
|
|
622
|
+
*,
|
|
623
|
+
persistent: bool = True,
|
|
624
|
+
) -> str:
|
|
625
|
+
"""Compile a rule definition to Datalog.
|
|
626
|
+
|
|
627
|
+
persistent=True → +reachable(Src, Dst) <- edge(Src, Dst)
|
|
628
|
+
persistent=False → reachable(Src, Dst) <- edge(Src, Dst)
|
|
629
|
+
"""
|
|
630
|
+
from inputlayer.relation import Relation
|
|
631
|
+
|
|
632
|
+
env = _VarEnv()
|
|
633
|
+
|
|
634
|
+
# Process condition first for join unification
|
|
635
|
+
if condition:
|
|
636
|
+
_process_join_condition(condition, env)
|
|
637
|
+
|
|
638
|
+
# Build head
|
|
639
|
+
head_parts = []
|
|
640
|
+
for col in head_columns:
|
|
641
|
+
expr = select_map.get(col)
|
|
642
|
+
if expr is not None:
|
|
643
|
+
compiled = compile_expr(expr, env)
|
|
644
|
+
head_parts.append(compiled)
|
|
645
|
+
else:
|
|
646
|
+
head_parts.append(column_to_variable(col))
|
|
647
|
+
|
|
648
|
+
# Build body atoms
|
|
649
|
+
body_atoms: list[str] = []
|
|
650
|
+
for rn, cls, alias in body_relations:
|
|
651
|
+
cols = Relation._get_columns(cls)
|
|
652
|
+
atom_parts = []
|
|
653
|
+
for col in cols:
|
|
654
|
+
ast_col = AstColumn(rn, col, alias)
|
|
655
|
+
var = env.lookup(ast_col)
|
|
656
|
+
if var is not None:
|
|
657
|
+
atom_parts.append(var)
|
|
658
|
+
else:
|
|
659
|
+
atom_parts.append("_")
|
|
660
|
+
body_atoms.append(f"{rn}({', '.join(atom_parts)})")
|
|
661
|
+
|
|
662
|
+
# Compile filter conditions
|
|
663
|
+
cond_parts: list[str] = []
|
|
664
|
+
if condition:
|
|
665
|
+
cond_parts = compile_bool_expr(condition, env)
|
|
666
|
+
cond_parts = [p for p in cond_parts if p]
|
|
667
|
+
|
|
668
|
+
all_body = body_atoms + cond_parts
|
|
669
|
+
prefix = "+" if persistent else ""
|
|
670
|
+
head_str = f"{prefix}{head_name}({', '.join(head_parts)})"
|
|
671
|
+
|
|
672
|
+
return f"{head_str} <- {', '.join(all_body)}"
|