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.
@@ -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
+ ]