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
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""KnowledgeGraph - the primary workspace for data, queries, and rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Any, AsyncIterator, Callable
|
|
7
|
+
|
|
8
|
+
from inputlayer._ast import BoolExpr, Expr, OrderedColumn
|
|
9
|
+
from inputlayer._proxy import ColumnProxy, RelationProxy, RelationRef
|
|
10
|
+
from inputlayer.auth import AclEntry
|
|
11
|
+
from inputlayer.compiler import (
|
|
12
|
+
compile_bulk_insert,
|
|
13
|
+
compile_conditional_delete,
|
|
14
|
+
compile_delete,
|
|
15
|
+
compile_insert,
|
|
16
|
+
compile_query,
|
|
17
|
+
compile_rule,
|
|
18
|
+
compile_schema,
|
|
19
|
+
)
|
|
20
|
+
from inputlayer.index import HnswIndex
|
|
21
|
+
from inputlayer.relation import Relation
|
|
22
|
+
from inputlayer.result import ResultSet
|
|
23
|
+
from inputlayer.session import Session
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from inputlayer.connection import Connection
|
|
27
|
+
from inputlayer.derived import Derived
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Data classes ──────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class RelationInfo:
|
|
34
|
+
name: str
|
|
35
|
+
row_count: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ColumnInfo:
|
|
40
|
+
name: str
|
|
41
|
+
type: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class RelationDescription:
|
|
46
|
+
name: str
|
|
47
|
+
columns: list[ColumnInfo]
|
|
48
|
+
row_count: int
|
|
49
|
+
sample: list[dict]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class RuleInfo:
|
|
54
|
+
name: str
|
|
55
|
+
clause_count: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class IndexInfo:
|
|
60
|
+
name: str
|
|
61
|
+
relation: str
|
|
62
|
+
column: str
|
|
63
|
+
metric: str
|
|
64
|
+
row_count: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class IndexStats:
|
|
69
|
+
name: str
|
|
70
|
+
row_count: int
|
|
71
|
+
layers: int
|
|
72
|
+
memory_bytes: int
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class InsertResult:
|
|
77
|
+
count: int
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class DeleteResult:
|
|
82
|
+
count: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ClearResult:
|
|
87
|
+
relations_cleared: int
|
|
88
|
+
facts_cleared: int
|
|
89
|
+
details: list[tuple[str, int]]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class ExplainResult:
|
|
94
|
+
datalog: str
|
|
95
|
+
plan: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class ServerStatus:
|
|
100
|
+
version: str
|
|
101
|
+
knowledge_graph: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class KnowledgeGraph:
|
|
105
|
+
"""Primary workspace for interacting with a knowledge graph."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, name: str, connection: Connection) -> None:
|
|
108
|
+
self._name = name
|
|
109
|
+
self._conn = connection
|
|
110
|
+
self._session = Session(connection)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def name(self) -> str:
|
|
114
|
+
return self._name
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def session(self) -> Session:
|
|
118
|
+
return self._session
|
|
119
|
+
|
|
120
|
+
# ── Schema ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
async def define(self, *relations: type[Relation]) -> None:
|
|
123
|
+
"""Deploy schema definitions. Idempotent."""
|
|
124
|
+
for rel in relations:
|
|
125
|
+
datalog = compile_schema(rel)
|
|
126
|
+
await self._conn.execute(datalog)
|
|
127
|
+
|
|
128
|
+
async def relations(self) -> list[RelationInfo]:
|
|
129
|
+
"""List all relations in this KG."""
|
|
130
|
+
result = await self._conn.execute(".rel")
|
|
131
|
+
return [
|
|
132
|
+
RelationInfo(name=row[0], row_count=int(row[1]) if len(row) > 1 else 0)
|
|
133
|
+
for row in result.rows
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
async def describe(self, relation: type[Relation] | str) -> RelationDescription:
|
|
137
|
+
"""Describe a relation's schema."""
|
|
138
|
+
name = relation if isinstance(relation, str) else Relation._resolve_name(relation)
|
|
139
|
+
result = await self._conn.execute(f".rel {name}")
|
|
140
|
+
columns = [ColumnInfo(name=row[0], type=row[1]) for row in result.rows]
|
|
141
|
+
return RelationDescription(name=name, columns=columns, row_count=0, sample=[])
|
|
142
|
+
|
|
143
|
+
async def drop_relation(self, relation: type[Relation] | str) -> None:
|
|
144
|
+
"""Drop a relation and all its data."""
|
|
145
|
+
name = relation if isinstance(relation, str) else Relation._resolve_name(relation)
|
|
146
|
+
await self._conn.execute(f".rel drop {name}")
|
|
147
|
+
|
|
148
|
+
# ── Insert ────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
async def insert(
|
|
151
|
+
self,
|
|
152
|
+
facts: Relation | list[Relation] | type[Relation],
|
|
153
|
+
data: dict | list[dict] | Any | None = None,
|
|
154
|
+
) -> InsertResult:
|
|
155
|
+
"""Insert facts into the knowledge graph."""
|
|
156
|
+
if isinstance(facts, type) and issubclass(facts, Relation):
|
|
157
|
+
# Bulk mode: relation class + data
|
|
158
|
+
if data is None:
|
|
159
|
+
raise ValueError("Must provide data when passing a Relation class")
|
|
160
|
+
rel_cls = facts
|
|
161
|
+
if isinstance(data, dict):
|
|
162
|
+
instances = [rel_cls(**data)]
|
|
163
|
+
elif isinstance(data, list):
|
|
164
|
+
instances = [rel_cls(**d) for d in data]
|
|
165
|
+
else:
|
|
166
|
+
# Try pandas DataFrame
|
|
167
|
+
try:
|
|
168
|
+
instances = [rel_cls(**row) for row in data.to_dict("records")]
|
|
169
|
+
except Exception:
|
|
170
|
+
raise TypeError(f"Unsupported data type: {type(data).__name__}")
|
|
171
|
+
if len(instances) == 1:
|
|
172
|
+
datalog = compile_insert(instances[0])
|
|
173
|
+
else:
|
|
174
|
+
datalog = compile_bulk_insert(rel_cls, instances)
|
|
175
|
+
elif isinstance(facts, list):
|
|
176
|
+
if not facts:
|
|
177
|
+
return InsertResult(count=0)
|
|
178
|
+
datalog = compile_bulk_insert(type(facts[0]), facts)
|
|
179
|
+
elif isinstance(facts, Relation):
|
|
180
|
+
datalog = compile_insert(facts)
|
|
181
|
+
else:
|
|
182
|
+
raise TypeError(f"Unsupported facts type: {type(facts).__name__}")
|
|
183
|
+
|
|
184
|
+
result = await self._conn.execute(datalog)
|
|
185
|
+
return InsertResult(count=len(result.rows) if result.rows else 0)
|
|
186
|
+
|
|
187
|
+
# ── Delete ────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
async def delete(
|
|
190
|
+
self,
|
|
191
|
+
facts: Relation | list[Relation] | type[Relation],
|
|
192
|
+
*,
|
|
193
|
+
where: Callable | None = None,
|
|
194
|
+
) -> DeleteResult:
|
|
195
|
+
"""Delete facts from the knowledge graph."""
|
|
196
|
+
if isinstance(facts, type) and issubclass(facts, Relation) and where is not None:
|
|
197
|
+
# Conditional delete
|
|
198
|
+
rel_cls = facts
|
|
199
|
+
proxy = RelationProxy(Relation._resolve_name(rel_cls))
|
|
200
|
+
condition = where(proxy)
|
|
201
|
+
datalog = compile_conditional_delete(rel_cls, condition)
|
|
202
|
+
elif isinstance(facts, list):
|
|
203
|
+
for fact in facts:
|
|
204
|
+
datalog = compile_delete(fact)
|
|
205
|
+
await self._conn.execute(datalog)
|
|
206
|
+
return DeleteResult(count=len(facts))
|
|
207
|
+
elif isinstance(facts, Relation):
|
|
208
|
+
datalog = compile_delete(facts)
|
|
209
|
+
else:
|
|
210
|
+
raise TypeError(f"Unsupported facts type: {type(facts).__name__}")
|
|
211
|
+
|
|
212
|
+
result = await self._conn.execute(datalog)
|
|
213
|
+
return DeleteResult(count=len(result.rows) if result.rows else 0)
|
|
214
|
+
|
|
215
|
+
# ── Query ─────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
async def query(
|
|
218
|
+
self,
|
|
219
|
+
*select: type[Relation] | ColumnProxy | Expr,
|
|
220
|
+
join: list[type[Relation] | RelationRef] | None = None,
|
|
221
|
+
on: Callable | None = None,
|
|
222
|
+
where: Callable | None = None,
|
|
223
|
+
order_by: ColumnProxy | OrderedColumn | None = None,
|
|
224
|
+
limit: int | None = None,
|
|
225
|
+
offset: int | None = None,
|
|
226
|
+
**computed: Expr,
|
|
227
|
+
) -> ResultSet:
|
|
228
|
+
"""Query the knowledge graph."""
|
|
229
|
+
# Convert ColumnProxy to AST
|
|
230
|
+
ast_select = []
|
|
231
|
+
relations = join or []
|
|
232
|
+
for s in select:
|
|
233
|
+
if isinstance(s, ColumnProxy):
|
|
234
|
+
ast_select.append(s._to_ast())
|
|
235
|
+
# Auto-add relation to join list if not present
|
|
236
|
+
if not any(
|
|
237
|
+
(isinstance(r, type) and Relation._resolve_name(r) == s.relation)
|
|
238
|
+
or (isinstance(r, RelationRef) and r.relation_name == s.relation)
|
|
239
|
+
for r in relations
|
|
240
|
+
):
|
|
241
|
+
# We can't auto-add without the class, but the relation name is enough
|
|
242
|
+
pass
|
|
243
|
+
elif isinstance(s, type) and issubclass(s, Relation):
|
|
244
|
+
ast_select.append(s)
|
|
245
|
+
if not any(
|
|
246
|
+
(isinstance(r, type) and r is s)
|
|
247
|
+
or (isinstance(r, RelationRef) and r.relation_cls is s)
|
|
248
|
+
for r in relations
|
|
249
|
+
):
|
|
250
|
+
relations.append(s)
|
|
251
|
+
else:
|
|
252
|
+
ast_select.append(s)
|
|
253
|
+
|
|
254
|
+
# Convert computed columns
|
|
255
|
+
ast_computed = {}
|
|
256
|
+
for k, v in computed.items():
|
|
257
|
+
if isinstance(v, ColumnProxy):
|
|
258
|
+
ast_computed[k] = v._to_ast()
|
|
259
|
+
else:
|
|
260
|
+
ast_computed[k] = v
|
|
261
|
+
|
|
262
|
+
# Build on condition
|
|
263
|
+
on_condition = None
|
|
264
|
+
if on and relations:
|
|
265
|
+
proxies = [
|
|
266
|
+
RelationProxy(
|
|
267
|
+
r.relation_name if isinstance(r, RelationRef) else Relation._resolve_name(r),
|
|
268
|
+
ref_alias=r.alias if isinstance(r, RelationRef) else None,
|
|
269
|
+
)
|
|
270
|
+
for r in relations
|
|
271
|
+
]
|
|
272
|
+
on_condition = on(*proxies)
|
|
273
|
+
|
|
274
|
+
# Build where condition
|
|
275
|
+
where_condition = None
|
|
276
|
+
if where and relations:
|
|
277
|
+
proxies = [
|
|
278
|
+
RelationProxy(
|
|
279
|
+
r.relation_name if isinstance(r, RelationRef) else Relation._resolve_name(r),
|
|
280
|
+
ref_alias=r.alias if isinstance(r, RelationRef) else None,
|
|
281
|
+
)
|
|
282
|
+
for r in relations
|
|
283
|
+
]
|
|
284
|
+
where_condition = where(*proxies)
|
|
285
|
+
|
|
286
|
+
# Convert order_by
|
|
287
|
+
order_ast = None
|
|
288
|
+
if order_by is not None:
|
|
289
|
+
if isinstance(order_by, ColumnProxy):
|
|
290
|
+
order_ast = order_by.asc()
|
|
291
|
+
elif isinstance(order_by, OrderedColumn):
|
|
292
|
+
order_ast = order_by
|
|
293
|
+
else:
|
|
294
|
+
order_ast = order_by
|
|
295
|
+
|
|
296
|
+
datalog = compile_query(
|
|
297
|
+
*ast_select,
|
|
298
|
+
relations=relations,
|
|
299
|
+
on_condition=on_condition,
|
|
300
|
+
where_condition=where_condition,
|
|
301
|
+
order_by=order_ast,
|
|
302
|
+
limit=limit,
|
|
303
|
+
offset=offset,
|
|
304
|
+
computed=ast_computed or None,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if isinstance(datalog, list):
|
|
308
|
+
# OR split → execute each and union
|
|
309
|
+
all_rows: list[list] = []
|
|
310
|
+
columns: list[str] = []
|
|
311
|
+
for q in datalog:
|
|
312
|
+
result = await self._conn.execute(q)
|
|
313
|
+
if not columns:
|
|
314
|
+
columns = result.columns
|
|
315
|
+
all_rows.extend(result.rows)
|
|
316
|
+
return ResultSet(columns=columns, rows=all_rows)
|
|
317
|
+
else:
|
|
318
|
+
result = await self._conn.execute(datalog)
|
|
319
|
+
rs = ResultSet(
|
|
320
|
+
columns=result.columns,
|
|
321
|
+
rows=result.rows,
|
|
322
|
+
row_count=result.row_count,
|
|
323
|
+
total_count=result.total_count,
|
|
324
|
+
truncated=result.truncated,
|
|
325
|
+
execution_time_ms=result.execution_time_ms,
|
|
326
|
+
row_provenance=result.row_provenance,
|
|
327
|
+
)
|
|
328
|
+
if result.metadata:
|
|
329
|
+
rs.has_ephemeral = result.metadata.get("has_ephemeral", False)
|
|
330
|
+
rs.ephemeral_sources = result.metadata.get("ephemeral_sources", [])
|
|
331
|
+
rs.warnings = result.metadata.get("warnings", [])
|
|
332
|
+
return rs
|
|
333
|
+
|
|
334
|
+
async def query_stream(
|
|
335
|
+
self,
|
|
336
|
+
*select: type[Relation] | ColumnProxy,
|
|
337
|
+
batch_size: int = 1000,
|
|
338
|
+
**kwargs: Any,
|
|
339
|
+
) -> AsyncIterator[list]:
|
|
340
|
+
"""Stream query results in batches."""
|
|
341
|
+
result = await self.query(*select, **kwargs)
|
|
342
|
+
for i in range(0, len(result.rows), batch_size):
|
|
343
|
+
yield result.rows[i : i + batch_size]
|
|
344
|
+
|
|
345
|
+
# ── Vector search ─────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
async def vector_search(
|
|
348
|
+
self,
|
|
349
|
+
relation: type[Relation],
|
|
350
|
+
query_vec: list[float],
|
|
351
|
+
*,
|
|
352
|
+
column: str | None = None,
|
|
353
|
+
k: int | None = None,
|
|
354
|
+
radius: float | None = None,
|
|
355
|
+
metric: str = "cosine",
|
|
356
|
+
where: Callable | None = None,
|
|
357
|
+
) -> ResultSet:
|
|
358
|
+
"""Perform a vector similarity search."""
|
|
359
|
+
from inputlayer.functions import cosine, euclidean, manhattan, dot
|
|
360
|
+
|
|
361
|
+
rel_name = Relation._resolve_name(relation)
|
|
362
|
+
cols = Relation._get_columns(relation)
|
|
363
|
+
|
|
364
|
+
# Find vector column if not specified
|
|
365
|
+
if column is None:
|
|
366
|
+
col_types = Relation._get_column_types(relation)
|
|
367
|
+
for c, tp in col_types.items():
|
|
368
|
+
from inputlayer.types import Vector, _VectorMeta
|
|
369
|
+
if tp is Vector or isinstance(tp, _VectorMeta):
|
|
370
|
+
column = c
|
|
371
|
+
break
|
|
372
|
+
if column is None:
|
|
373
|
+
raise ValueError(f"No vector column found in {rel_name}")
|
|
374
|
+
|
|
375
|
+
# Build query using top_k or within_radius
|
|
376
|
+
vec_str = "[" + ", ".join(str(v) for v in query_vec) + "]"
|
|
377
|
+
dist_fn = {"cosine": "cosine", "euclidean": "euclidean", "manhattan": "manhattan", "dot_product": "dot"}
|
|
378
|
+
fn_name = dist_fn.get(metric, "cosine")
|
|
379
|
+
|
|
380
|
+
if k is not None:
|
|
381
|
+
# top_k query
|
|
382
|
+
col_vars = ", ".join(f"X{i}" for i in range(len(cols)))
|
|
383
|
+
vec_var = f"X{cols.index(column)}"
|
|
384
|
+
dist_assign = f"Dist = {fn_name}({vec_var}, {vec_str})"
|
|
385
|
+
query = f"?top_k<{k}, {col_vars}, Dist:asc> <- {rel_name}({col_vars}), {dist_assign}"
|
|
386
|
+
elif radius is not None:
|
|
387
|
+
col_vars = ", ".join(f"X{i}" for i in range(len(cols)))
|
|
388
|
+
vec_var = f"X{cols.index(column)}"
|
|
389
|
+
dist_assign = f"Dist = {fn_name}({vec_var}, {vec_str})"
|
|
390
|
+
query = f"?within_radius<{radius}, {col_vars}, Dist:asc> <- {rel_name}({col_vars}), {dist_assign}"
|
|
391
|
+
else:
|
|
392
|
+
raise ValueError("Must specify either k or radius")
|
|
393
|
+
|
|
394
|
+
result = await self._conn.execute(query)
|
|
395
|
+
return ResultSet(
|
|
396
|
+
columns=result.columns,
|
|
397
|
+
rows=result.rows,
|
|
398
|
+
row_count=result.row_count,
|
|
399
|
+
total_count=result.total_count,
|
|
400
|
+
truncated=result.truncated,
|
|
401
|
+
execution_time_ms=result.execution_time_ms,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# ── Rules ─────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
async def define_rules(self, *targets: type[Derived]) -> None:
|
|
407
|
+
"""Deploy persistent rule definitions."""
|
|
408
|
+
from inputlayer.derived import Derived
|
|
409
|
+
|
|
410
|
+
for target in targets:
|
|
411
|
+
head_name = Relation._resolve_name(target)
|
|
412
|
+
head_columns = Relation._get_columns(target)
|
|
413
|
+
for clause in target.rules:
|
|
414
|
+
datalog = compile_rule(
|
|
415
|
+
head_name,
|
|
416
|
+
head_columns,
|
|
417
|
+
clause.select_map,
|
|
418
|
+
clause.relations,
|
|
419
|
+
clause.condition,
|
|
420
|
+
persistent=True,
|
|
421
|
+
)
|
|
422
|
+
await self._conn.execute(datalog)
|
|
423
|
+
|
|
424
|
+
async def list_rules(self) -> list[RuleInfo]:
|
|
425
|
+
"""List all rules in this KG."""
|
|
426
|
+
result = await self._conn.execute(".rule list")
|
|
427
|
+
rules = []
|
|
428
|
+
for row in result.rows:
|
|
429
|
+
rules.append(RuleInfo(name=row[0], clause_count=int(row[1]) if len(row) > 1 else 1))
|
|
430
|
+
return rules
|
|
431
|
+
|
|
432
|
+
async def rule_definition(self, name: str | type) -> list[str]:
|
|
433
|
+
"""Get the Datalog definition of a rule."""
|
|
434
|
+
if isinstance(name, type):
|
|
435
|
+
name = Relation._resolve_name(name)
|
|
436
|
+
result = await self._conn.execute(f".rule show {name}")
|
|
437
|
+
return [row[0] for row in result.rows]
|
|
438
|
+
|
|
439
|
+
async def drop_rule(self, name: str | type) -> None:
|
|
440
|
+
"""Drop all clauses of a rule."""
|
|
441
|
+
if isinstance(name, type):
|
|
442
|
+
name = Relation._resolve_name(name)
|
|
443
|
+
await self._conn.execute(f".rule drop {name}")
|
|
444
|
+
|
|
445
|
+
async def drop_rule_clause(self, name: str | type, index: int) -> None:
|
|
446
|
+
"""Remove a specific clause from a rule (1-based index)."""
|
|
447
|
+
if isinstance(name, type):
|
|
448
|
+
name = Relation._resolve_name(name)
|
|
449
|
+
await self._conn.execute(f".rule remove {name} {index}")
|
|
450
|
+
|
|
451
|
+
async def edit_rule_clause(self, name: str | type, index: int, clause: Any) -> None:
|
|
452
|
+
"""Replace a specific rule clause (remove + re-add)."""
|
|
453
|
+
await self.drop_rule_clause(name, index)
|
|
454
|
+
# Re-add: compile the new clause
|
|
455
|
+
if isinstance(name, type):
|
|
456
|
+
head_name = Relation._resolve_name(name)
|
|
457
|
+
head_columns = Relation._get_columns(name)
|
|
458
|
+
else:
|
|
459
|
+
head_name = name
|
|
460
|
+
head_columns = list(clause.select_map.keys())
|
|
461
|
+
datalog = compile_rule(
|
|
462
|
+
head_name,
|
|
463
|
+
head_columns,
|
|
464
|
+
clause.select_map,
|
|
465
|
+
clause.relations,
|
|
466
|
+
clause.condition,
|
|
467
|
+
persistent=True,
|
|
468
|
+
)
|
|
469
|
+
await self._conn.execute(datalog)
|
|
470
|
+
|
|
471
|
+
async def clear_rule(self, name: str | type) -> None:
|
|
472
|
+
"""Clear a rule's materialized data."""
|
|
473
|
+
if isinstance(name, type):
|
|
474
|
+
name = Relation._resolve_name(name)
|
|
475
|
+
await self._conn.execute(f".rule clear {name}")
|
|
476
|
+
|
|
477
|
+
async def drop_rules_by_prefix(self, prefix: str) -> None:
|
|
478
|
+
"""Drop all rules whose names start with prefix."""
|
|
479
|
+
await self._conn.execute(f".rule drop prefix {prefix}")
|
|
480
|
+
|
|
481
|
+
# ── Indexes ───────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
async def create_index(self, index: HnswIndex) -> None:
|
|
484
|
+
"""Create an HNSW vector index."""
|
|
485
|
+
await self._conn.execute(index.to_datalog())
|
|
486
|
+
|
|
487
|
+
async def list_indexes(self) -> list[IndexInfo]:
|
|
488
|
+
"""List all indexes."""
|
|
489
|
+
result = await self._conn.execute(".index list")
|
|
490
|
+
indexes = []
|
|
491
|
+
for row in result.rows:
|
|
492
|
+
indexes.append(IndexInfo(
|
|
493
|
+
name=row[0],
|
|
494
|
+
relation=row[1] if len(row) > 1 else "",
|
|
495
|
+
column=row[2] if len(row) > 2 else "",
|
|
496
|
+
metric=row[3] if len(row) > 3 else "",
|
|
497
|
+
row_count=int(row[4]) if len(row) > 4 else 0,
|
|
498
|
+
))
|
|
499
|
+
return indexes
|
|
500
|
+
|
|
501
|
+
async def index_stats(self, name: str) -> IndexStats:
|
|
502
|
+
"""Get statistics for an index."""
|
|
503
|
+
result = await self._conn.execute(f".index stats {name}")
|
|
504
|
+
row = result.rows[0] if result.rows else [name, 0, 0, 0]
|
|
505
|
+
return IndexStats(
|
|
506
|
+
name=str(row[0]),
|
|
507
|
+
row_count=int(row[1]) if len(row) > 1 else 0,
|
|
508
|
+
layers=int(row[2]) if len(row) > 2 else 0,
|
|
509
|
+
memory_bytes=int(row[3]) if len(row) > 3 else 0,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
async def drop_index(self, name: str) -> None:
|
|
513
|
+
"""Drop an index."""
|
|
514
|
+
await self._conn.execute(f".index drop {name}")
|
|
515
|
+
|
|
516
|
+
async def rebuild_index(self, name: str) -> None:
|
|
517
|
+
"""Rebuild an index."""
|
|
518
|
+
await self._conn.execute(f".index rebuild {name}")
|
|
519
|
+
|
|
520
|
+
# ── ACL ───────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
async def grant_access(self, username: str, role: str) -> None:
|
|
523
|
+
"""Grant per-KG access."""
|
|
524
|
+
await self._conn.execute(f".kg acl grant {self._name} {username} {role}")
|
|
525
|
+
|
|
526
|
+
async def revoke_access(self, username: str) -> None:
|
|
527
|
+
"""Revoke per-KG access."""
|
|
528
|
+
await self._conn.execute(f".kg acl revoke {self._name} {username}")
|
|
529
|
+
|
|
530
|
+
async def list_acl(self) -> list[AclEntry]:
|
|
531
|
+
"""List ACL entries."""
|
|
532
|
+
result = await self._conn.execute(f".kg acl list {self._name}")
|
|
533
|
+
return [
|
|
534
|
+
AclEntry(username=row[0], role=row[1])
|
|
535
|
+
for row in result.rows
|
|
536
|
+
if len(row) >= 2
|
|
537
|
+
]
|
|
538
|
+
|
|
539
|
+
# ── Meta ──────────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
async def explain(self, *select: Any, **kwargs: Any) -> ExplainResult:
|
|
542
|
+
"""Show the query plan without executing."""
|
|
543
|
+
datalog = compile_query(*select, **kwargs)
|
|
544
|
+
if isinstance(datalog, list):
|
|
545
|
+
datalog = datalog[0]
|
|
546
|
+
result = await self._conn.execute(f".explain {datalog}")
|
|
547
|
+
plan_text = "\n".join(row[0] for row in result.rows)
|
|
548
|
+
return ExplainResult(datalog=datalog, plan=plan_text)
|
|
549
|
+
|
|
550
|
+
async def compact(self) -> None:
|
|
551
|
+
"""Trigger storage compaction."""
|
|
552
|
+
await self._conn.execute(".compact")
|
|
553
|
+
|
|
554
|
+
async def status(self) -> ServerStatus:
|
|
555
|
+
"""Get server status."""
|
|
556
|
+
result = await self._conn.execute(".status")
|
|
557
|
+
row = result.rows[0] if result.rows else ["unknown", "unknown"]
|
|
558
|
+
return ServerStatus(
|
|
559
|
+
version=str(row[0]) if len(row) > 0 else "unknown",
|
|
560
|
+
knowledge_graph=str(row[1]) if len(row) > 1 else self._name,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
async def load(self, path: str, *, mode: str | None = None) -> None:
|
|
564
|
+
"""Load data from a file."""
|
|
565
|
+
cmd = f".load {path}"
|
|
566
|
+
if mode:
|
|
567
|
+
cmd += f" {mode}"
|
|
568
|
+
await self._conn.execute(cmd)
|
|
569
|
+
|
|
570
|
+
async def clear_prefix(self, prefix: str) -> ClearResult:
|
|
571
|
+
"""Clear all relations matching a prefix."""
|
|
572
|
+
result = await self._conn.execute(f".clear prefix {prefix}")
|
|
573
|
+
return ClearResult(
|
|
574
|
+
relations_cleared=len(result.rows),
|
|
575
|
+
facts_cleared=sum(int(row[1]) for row in result.rows if len(row) > 1),
|
|
576
|
+
details=[(row[0], int(row[1])) for row in result.rows if len(row) > 1],
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
async def execute(self, datalog: str) -> ResultSet:
|
|
580
|
+
"""Execute raw Datalog."""
|
|
581
|
+
result = await self._conn.execute(datalog)
|
|
582
|
+
return ResultSet(
|
|
583
|
+
columns=result.columns,
|
|
584
|
+
rows=result.rows,
|
|
585
|
+
row_count=result.row_count,
|
|
586
|
+
total_count=result.total_count,
|
|
587
|
+
truncated=result.truncated,
|
|
588
|
+
execution_time_ms=result.execution_time_ms,
|
|
589
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""InputLayer migration system - Django-style schema versioning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from inputlayer.migrations.operations import (
|
|
8
|
+
CreateIndex,
|
|
9
|
+
CreateRelation,
|
|
10
|
+
CreateRule,
|
|
11
|
+
DropIndex,
|
|
12
|
+
DropRelation,
|
|
13
|
+
DropRule,
|
|
14
|
+
Operation,
|
|
15
|
+
ReplaceRule,
|
|
16
|
+
RunDatalog,
|
|
17
|
+
operation_from_dict,
|
|
18
|
+
)
|
|
19
|
+
from inputlayer.migrations.state import ModelState
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Migration:
|
|
23
|
+
"""Base class for migration files.
|
|
24
|
+
|
|
25
|
+
Subclass as ``M`` in each migration file::
|
|
26
|
+
|
|
27
|
+
class M(Migration):
|
|
28
|
+
dependencies = ["0001_initial"]
|
|
29
|
+
operations = [ops.CreateRelation(...)]
|
|
30
|
+
state = {"relations": {...}, "rules": {...}, "indexes": {}}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
dependencies: list[str] = []
|
|
34
|
+
operations: list[Operation] = []
|
|
35
|
+
state: dict[str, Any] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Migration",
|
|
40
|
+
"ModelState",
|
|
41
|
+
"CreateRelation",
|
|
42
|
+
"DropRelation",
|
|
43
|
+
"CreateRule",
|
|
44
|
+
"DropRule",
|
|
45
|
+
"ReplaceRule",
|
|
46
|
+
"CreateIndex",
|
|
47
|
+
"DropIndex",
|
|
48
|
+
"RunDatalog",
|
|
49
|
+
"Operation",
|
|
50
|
+
"operation_from_dict",
|
|
51
|
+
]
|