knowledge-graph-rdbms 0.1.7__tar.gz → 0.1.8__tar.gz
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.
- knowledge_graph_rdbms-0.1.7/README.md → knowledge_graph_rdbms-0.1.8/PKG-INFO +116 -0
- knowledge_graph_rdbms-0.1.7/PKG-INFO → knowledge_graph_rdbms-0.1.8/README.md +77 -31
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/__init__.py +1 -1
- knowledge_graph_rdbms-0.1.8/kgrdbms/iceberg.py +183 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/mcp_server.py +33 -8
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/virtual.py +63 -2
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/pyproject.toml +3 -2
- knowledge_graph_rdbms-0.1.8/tests/test_iceberg.py +289 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/uv.lock +1637 -2
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/.claude/skills/kg-compose/SKILL.md +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/.github/workflows/ci.yml +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/.github/workflows/publish.yml +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/.gitignore +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/CLAUDE.md +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/CODE_OF_CONDUCT.md +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/CONTRIBUTING.md +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/LICENSE +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/SECURITY.md +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/assets/crossover.png +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/assets/read_latency.png +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/assets/runtimes.png +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/assets/write_throughput.png +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/README.md +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/benchmark.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/charts.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/neo4j/README.md +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/neo4j/headtohead.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/postgres/README.md +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/postgres/benchmark.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/postgres/charts.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/runtimes/compare.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/runtimes/run_bun.js +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/runtimes/run_node.mjs +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/runtimes/run_python.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backbone.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backends/__init__.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backends/base.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backends/neo4j.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backends/postgres.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backends/sqlite.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/cli.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/events.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/federation.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/graph.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/invariants.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/policy.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/rdf.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/resolver.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/service.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_backbone.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_bulk.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_cli.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_events.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_federation.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_graph.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_mcp_server.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_policy.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_postgres.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_rdf.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_schema.py +0 -0
- {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_virtual.py +0 -0
|
@@ -1,3 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: knowledge-graph-rdbms
|
|
3
|
+
Version: 0.1.8
|
|
4
|
+
Summary: A label property graph on an RDBMS (SQLite): nodes, typed edges, an append-only event log, and an optional MCP server.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cunicopia-dev/knowledge-graph-rdbms
|
|
6
|
+
Project-URL: Repository, https://github.com/cunicopia-dev/knowledge-graph-rdbms
|
|
7
|
+
Project-URL: Issues, https://github.com/cunicopia-dev/knowledge-graph-rdbms/issues
|
|
8
|
+
Author: Keith Cunic
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: event-sourcing,graph,knowledge-graph,label-property-graph,mcp,sqlite
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Database :: Database Engines/Servers
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Provides-Extra: charts
|
|
18
|
+
Requires-Dist: matplotlib>=3.7; extra == 'charts'
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: duckdb>=1.1; extra == 'dev'
|
|
21
|
+
Requires-Dist: mcp>=1.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
|
|
23
|
+
Requires-Dist: pyarrow>=14; extra == 'dev'
|
|
24
|
+
Requires-Dist: pyiceberg[sql-sqlite]>=0.7; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: rdflib>=7.0; extra == 'dev'
|
|
27
|
+
Provides-Extra: iceberg
|
|
28
|
+
Requires-Dist: boto3>=1.34; extra == 'iceberg'
|
|
29
|
+
Requires-Dist: duckdb>=1.1; extra == 'iceberg'
|
|
30
|
+
Requires-Dist: pyarrow>=14; extra == 'iceberg'
|
|
31
|
+
Requires-Dist: pyiceberg[glue,s3fs,sql-sqlite]>=0.7; extra == 'iceberg'
|
|
32
|
+
Provides-Extra: mcp
|
|
33
|
+
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
34
|
+
Provides-Extra: postgres
|
|
35
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
36
|
+
Provides-Extra: rdf
|
|
37
|
+
Requires-Dist: rdflib>=7.0; extra == 'rdf'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
1
40
|
# knowledge-graph-rdbms
|
|
2
41
|
|
|
3
42
|

|
|
@@ -498,6 +537,17 @@ traversal time the resolver runs that query, parameterized by the node you're
|
|
|
498
537
|
standing on, and synthesizes the edges live. Zero copy, always current, one
|
|
499
538
|
source of truth — Ontology-Based Data Access in the graph's own terms.
|
|
500
539
|
|
|
540
|
+
```mermaid
|
|
541
|
+
flowchart LR
|
|
542
|
+
Q(["kg_edges('company:NVDA')"]) --> U{"union traversal"}
|
|
543
|
+
STORE[("event-sourced graph<br/>curated, logged edges")] -->|"stored edges"| U
|
|
544
|
+
U -->|"for each _VirtualEdge binding"| R["resolver<br/>runs bound SQL, ? = 'NVDA'"]
|
|
545
|
+
R -.->|"parameterized query"| EXT[("operational store<br/>system-of-record")]
|
|
546
|
+
EXT -.->|"rows → synthesized edges<br/>_virtual: true"| R
|
|
547
|
+
R --> U
|
|
548
|
+
U --> OUT[/"merged edge list<br/>stored + live"/]
|
|
549
|
+
```
|
|
550
|
+
|
|
501
551
|
```python
|
|
502
552
|
# Bind CO_HELD_WITH to a query over the operational store (here, any DB-API source)
|
|
503
553
|
kg_virtual_edge_add(
|
|
@@ -530,6 +580,72 @@ schema-graph / data-graph split: keep the curated **schema** (types, contracts,
|
|
|
530
580
|
doctrine) in a portable SQLite ontology, and **virtualize the populated extension**
|
|
531
581
|
straight out of your system-of-record — no ETL, no drift.
|
|
532
582
|
|
|
583
|
+
```mermaid
|
|
584
|
+
flowchart TB
|
|
585
|
+
subgraph SG["schema graph — portable SQLite ontology"]
|
|
586
|
+
T["types · contracts · doctrine"]
|
|
587
|
+
B["_VirtualEdge bindings<br/>(edge TYPE + SQL + dsn_env)"]
|
|
588
|
+
end
|
|
589
|
+
subgraph DG["data graph — virtualized extension"]
|
|
590
|
+
E["live edges, resolved on traversal"]
|
|
591
|
+
end
|
|
592
|
+
SOR[("system-of-record<br/>operational store")]
|
|
593
|
+
B -.->|"resolves against"| SOR
|
|
594
|
+
SOR -.->|"synthesizes"| E
|
|
595
|
+
T --- E
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Iceberg sources: virtualize the lakehouse
|
|
599
|
+
|
|
600
|
+
The system-of-record isn't always an operational database. A lot of
|
|
601
|
+
machine-generated relationship data lands in a **lakehouse** — Apache Iceberg
|
|
602
|
+
tables in object storage, versioned by snapshot and governed by a catalog. A
|
|
603
|
+
virtual edge can resolve straight out of Iceberg, with **DuckDB** as the scan
|
|
604
|
+
engine, by setting `source_type="iceberg"`:
|
|
605
|
+
|
|
606
|
+
```python
|
|
607
|
+
kg_virtual_edge_add(
|
|
608
|
+
edge_type="CO_HELD_WITH",
|
|
609
|
+
query="SELECT b AS to_id, shared FROM co_held WHERE a = ?", # '?' — DuckDB binds it
|
|
610
|
+
source_type="iceberg",
|
|
611
|
+
catalog={ # pyiceberg catalog props; any value as
|
|
612
|
+
"name": "lake", # "env:VAR" is resolved from the env, so
|
|
613
|
+
"type": "rest", # tokens/keys never sit in the graph
|
|
614
|
+
"uri": "env:ICEBERG_REST_URI",
|
|
615
|
+
"warehouse": "s3://lake/wh",
|
|
616
|
+
},
|
|
617
|
+
table="analytics.co_held", # namespace.table — query uses the leaf name
|
|
618
|
+
# snapshot_id=..., # optional: pin a version for time-travel
|
|
619
|
+
source="id_slug", target_id_template="company:{value}",
|
|
620
|
+
prop_cols=["shared"], directions="both", ontology="market",
|
|
621
|
+
)
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
The split mirrors Iceberg's own architecture: **pyiceberg owns identity and
|
|
625
|
+
versioning** — it loads the catalog, maps `namespace.table` to the current
|
|
626
|
+
metadata pointer (or a pinned `snapshot_id`), the layer a schema graph actually
|
|
627
|
+
cares about; **DuckDB owns the scan** — it reads the resolved table (format
|
|
628
|
+
version 2 today; newer versions as the extension gains them) and answers the
|
|
629
|
+
binding's parameterized query. The table is exposed as a named DuckDB view, so
|
|
630
|
+
the query is written against an ordinary table name — *identical* to the
|
|
631
|
+
SQLite/Postgres case, and the resolver's bind path is unchanged. Install with the
|
|
632
|
+
`iceberg` extra (`pip install 'knowledge-graph-rdbms[iceberg]'`).
|
|
633
|
+
|
|
634
|
+
Any catalog pyiceberg speaks works — swap the `catalog` dict:
|
|
635
|
+
|
|
636
|
+
| Catalog | `catalog` config |
|
|
637
|
+
| --- | --- |
|
|
638
|
+
| **AWS Glue** | `{"type": "glue"}` (region/creds from the AWS chain) |
|
|
639
|
+
| **S3 Tables** (managed Iceberg) | `{"type": "rest", "uri": "https://s3tables.<region>.amazonaws.com/iceberg", "warehouse": "<table-bucket-arn>", "rest.sigv4-enabled": "true", "rest.signing-name": "s3tables", "rest.signing-region": "<region>"}` |
|
|
640
|
+
| **Local / SQL catalog** | `{"type": "sql", "uri": "sqlite:///…", "warehouse": "file://…"}` |
|
|
641
|
+
|
|
642
|
+
When the resolved metadata lives on `s3://` (S3 Tables, Glue, or a plain S3
|
|
643
|
+
lake), the opener loads DuckDB's `httpfs`/`aws` extensions and a credential-chain
|
|
644
|
+
secret automatically — DuckDB reads the managed storage with the host's AWS
|
|
645
|
+
identity, sigv4 included. This is proven live against AWS S3 Tables in
|
|
646
|
+
`tests/test_iceberg.py::test_s3tables_live_resolve` (opt-in via
|
|
647
|
+
`KG_ICEBERG_S3TABLES_ARN`).
|
|
648
|
+
|
|
533
649
|
Tools: `kg_virtual_edge_add`, `kg_virtual_edges_list`, `kg_virtual_edge_remove`.
|
|
534
650
|
|
|
535
651
|
---
|
|
@@ -1,34 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: knowledge-graph-rdbms
|
|
3
|
-
Version: 0.1.7
|
|
4
|
-
Summary: A label property graph on an RDBMS (SQLite): nodes, typed edges, an append-only event log, and an optional MCP server.
|
|
5
|
-
Project-URL: Homepage, https://github.com/cunicopia-dev/knowledge-graph-rdbms
|
|
6
|
-
Project-URL: Repository, https://github.com/cunicopia-dev/knowledge-graph-rdbms
|
|
7
|
-
Project-URL: Issues, https://github.com/cunicopia-dev/knowledge-graph-rdbms/issues
|
|
8
|
-
Author: Keith Cunic
|
|
9
|
-
License: MIT
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Keywords: event-sourcing,graph,knowledge-graph,label-property-graph,mcp,sqlite
|
|
12
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Topic :: Database :: Database Engines/Servers
|
|
15
|
-
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
-
Requires-Python: >=3.10
|
|
17
|
-
Provides-Extra: charts
|
|
18
|
-
Requires-Dist: matplotlib>=3.7; extra == 'charts'
|
|
19
|
-
Provides-Extra: dev
|
|
20
|
-
Requires-Dist: mcp>=1.0; extra == 'dev'
|
|
21
|
-
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
|
|
22
|
-
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
23
|
-
Requires-Dist: rdflib>=7.0; extra == 'dev'
|
|
24
|
-
Provides-Extra: mcp
|
|
25
|
-
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
26
|
-
Provides-Extra: postgres
|
|
27
|
-
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
28
|
-
Provides-Extra: rdf
|
|
29
|
-
Requires-Dist: rdflib>=7.0; extra == 'rdf'
|
|
30
|
-
Description-Content-Type: text/markdown
|
|
31
|
-
|
|
32
1
|
# knowledge-graph-rdbms
|
|
33
2
|
|
|
34
3
|

|
|
@@ -529,6 +498,17 @@ traversal time the resolver runs that query, parameterized by the node you're
|
|
|
529
498
|
standing on, and synthesizes the edges live. Zero copy, always current, one
|
|
530
499
|
source of truth — Ontology-Based Data Access in the graph's own terms.
|
|
531
500
|
|
|
501
|
+
```mermaid
|
|
502
|
+
flowchart LR
|
|
503
|
+
Q(["kg_edges('company:NVDA')"]) --> U{"union traversal"}
|
|
504
|
+
STORE[("event-sourced graph<br/>curated, logged edges")] -->|"stored edges"| U
|
|
505
|
+
U -->|"for each _VirtualEdge binding"| R["resolver<br/>runs bound SQL, ? = 'NVDA'"]
|
|
506
|
+
R -.->|"parameterized query"| EXT[("operational store<br/>system-of-record")]
|
|
507
|
+
EXT -.->|"rows → synthesized edges<br/>_virtual: true"| R
|
|
508
|
+
R --> U
|
|
509
|
+
U --> OUT[/"merged edge list<br/>stored + live"/]
|
|
510
|
+
```
|
|
511
|
+
|
|
532
512
|
```python
|
|
533
513
|
# Bind CO_HELD_WITH to a query over the operational store (here, any DB-API source)
|
|
534
514
|
kg_virtual_edge_add(
|
|
@@ -561,6 +541,72 @@ schema-graph / data-graph split: keep the curated **schema** (types, contracts,
|
|
|
561
541
|
doctrine) in a portable SQLite ontology, and **virtualize the populated extension**
|
|
562
542
|
straight out of your system-of-record — no ETL, no drift.
|
|
563
543
|
|
|
544
|
+
```mermaid
|
|
545
|
+
flowchart TB
|
|
546
|
+
subgraph SG["schema graph — portable SQLite ontology"]
|
|
547
|
+
T["types · contracts · doctrine"]
|
|
548
|
+
B["_VirtualEdge bindings<br/>(edge TYPE + SQL + dsn_env)"]
|
|
549
|
+
end
|
|
550
|
+
subgraph DG["data graph — virtualized extension"]
|
|
551
|
+
E["live edges, resolved on traversal"]
|
|
552
|
+
end
|
|
553
|
+
SOR[("system-of-record<br/>operational store")]
|
|
554
|
+
B -.->|"resolves against"| SOR
|
|
555
|
+
SOR -.->|"synthesizes"| E
|
|
556
|
+
T --- E
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
### Iceberg sources: virtualize the lakehouse
|
|
560
|
+
|
|
561
|
+
The system-of-record isn't always an operational database. A lot of
|
|
562
|
+
machine-generated relationship data lands in a **lakehouse** — Apache Iceberg
|
|
563
|
+
tables in object storage, versioned by snapshot and governed by a catalog. A
|
|
564
|
+
virtual edge can resolve straight out of Iceberg, with **DuckDB** as the scan
|
|
565
|
+
engine, by setting `source_type="iceberg"`:
|
|
566
|
+
|
|
567
|
+
```python
|
|
568
|
+
kg_virtual_edge_add(
|
|
569
|
+
edge_type="CO_HELD_WITH",
|
|
570
|
+
query="SELECT b AS to_id, shared FROM co_held WHERE a = ?", # '?' — DuckDB binds it
|
|
571
|
+
source_type="iceberg",
|
|
572
|
+
catalog={ # pyiceberg catalog props; any value as
|
|
573
|
+
"name": "lake", # "env:VAR" is resolved from the env, so
|
|
574
|
+
"type": "rest", # tokens/keys never sit in the graph
|
|
575
|
+
"uri": "env:ICEBERG_REST_URI",
|
|
576
|
+
"warehouse": "s3://lake/wh",
|
|
577
|
+
},
|
|
578
|
+
table="analytics.co_held", # namespace.table — query uses the leaf name
|
|
579
|
+
# snapshot_id=..., # optional: pin a version for time-travel
|
|
580
|
+
source="id_slug", target_id_template="company:{value}",
|
|
581
|
+
prop_cols=["shared"], directions="both", ontology="market",
|
|
582
|
+
)
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
The split mirrors Iceberg's own architecture: **pyiceberg owns identity and
|
|
586
|
+
versioning** — it loads the catalog, maps `namespace.table` to the current
|
|
587
|
+
metadata pointer (or a pinned `snapshot_id`), the layer a schema graph actually
|
|
588
|
+
cares about; **DuckDB owns the scan** — it reads the resolved table (format
|
|
589
|
+
version 2 today; newer versions as the extension gains them) and answers the
|
|
590
|
+
binding's parameterized query. The table is exposed as a named DuckDB view, so
|
|
591
|
+
the query is written against an ordinary table name — *identical* to the
|
|
592
|
+
SQLite/Postgres case, and the resolver's bind path is unchanged. Install with the
|
|
593
|
+
`iceberg` extra (`pip install 'knowledge-graph-rdbms[iceberg]'`).
|
|
594
|
+
|
|
595
|
+
Any catalog pyiceberg speaks works — swap the `catalog` dict:
|
|
596
|
+
|
|
597
|
+
| Catalog | `catalog` config |
|
|
598
|
+
| --- | --- |
|
|
599
|
+
| **AWS Glue** | `{"type": "glue"}` (region/creds from the AWS chain) |
|
|
600
|
+
| **S3 Tables** (managed Iceberg) | `{"type": "rest", "uri": "https://s3tables.<region>.amazonaws.com/iceberg", "warehouse": "<table-bucket-arn>", "rest.sigv4-enabled": "true", "rest.signing-name": "s3tables", "rest.signing-region": "<region>"}` |
|
|
601
|
+
| **Local / SQL catalog** | `{"type": "sql", "uri": "sqlite:///…", "warehouse": "file://…"}` |
|
|
602
|
+
|
|
603
|
+
When the resolved metadata lives on `s3://` (S3 Tables, Glue, or a plain S3
|
|
604
|
+
lake), the opener loads DuckDB's `httpfs`/`aws` extensions and a credential-chain
|
|
605
|
+
secret automatically — DuckDB reads the managed storage with the host's AWS
|
|
606
|
+
identity, sigv4 included. This is proven live against AWS S3 Tables in
|
|
607
|
+
`tests/test_iceberg.py::test_s3tables_live_resolve` (opt-in via
|
|
608
|
+
`KG_ICEBERG_S3TABLES_ARN`).
|
|
609
|
+
|
|
564
610
|
Tools: `kg_virtual_edge_add`, `kg_virtual_edges_list`, `kg_virtual_edge_remove`.
|
|
565
611
|
|
|
566
612
|
---
|
|
@@ -12,7 +12,7 @@ A small, dependency-free knowledge-graph core:
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
-
__version__ = "0.1.
|
|
15
|
+
__version__ = "0.1.8"
|
|
16
16
|
|
|
17
17
|
from kgrdbms.graph import Edge, Graph, Node, default_graph_path, slug
|
|
18
18
|
from kgrdbms.federation import Federation, FederatedNode, Located
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Apache Iceberg as a virtual-edge source.
|
|
2
|
+
|
|
3
|
+
A virtual edge normally resolves against an operational SQL store — Postgres or
|
|
4
|
+
SQLite, the live system of record. But a great deal of machine-generated
|
|
5
|
+
relationship data does not live there; it lands in a lakehouse: Iceberg tables in
|
|
6
|
+
object storage, versioned by snapshot and governed by a catalog. This module lets
|
|
7
|
+
a binding name a *catalog + table* and resolve edges straight out of Iceberg, with
|
|
8
|
+
DuckDB as the query engine — so the graph reads the lake live, copies nothing, and
|
|
9
|
+
honors the same parameterized-query contract as every other virtual edge.
|
|
10
|
+
|
|
11
|
+
The division of labor mirrors Iceberg's own architecture:
|
|
12
|
+
|
|
13
|
+
* **pyiceberg owns identity and versioning.** It loads the catalog, maps a
|
|
14
|
+
``namespace.table`` name to the current metadata pointer, and — when a
|
|
15
|
+
``snapshot_id`` is bound — pins a specific version for time-travel reads. This
|
|
16
|
+
is precisely the layer a schema graph cares about: the stable name and the
|
|
17
|
+
snapshot, not the bytes.
|
|
18
|
+
|
|
19
|
+
* **DuckDB owns the scan.** Given the resolved metadata location, DuckDB's
|
|
20
|
+
``iceberg`` extension reads the table (format-version 2 today; newer format
|
|
21
|
+
versions transparently as the extension gains them) and answers the binding's
|
|
22
|
+
SQL. The table is exposed as a named DuckDB VIEW, so the binding's query is
|
|
23
|
+
written against an ordinary table name — byte-for-byte identical to the
|
|
24
|
+
Postgres/SQLite case, which is why the resolver's bind path is untouched.
|
|
25
|
+
|
|
26
|
+
Credentials never live in the graph. Any catalog property may be written as
|
|
27
|
+
``env:VAR_NAME`` and is resolved from the environment at query time — the same
|
|
28
|
+
discipline as ``dsn_env`` on a SQL virtual edge.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
# DuckDB's positional placeholder — identical to sqlite's, so resolve()'s
|
|
36
|
+
# bind-by-marker logic needs no special case once we hand it this connection.
|
|
37
|
+
MARKER = "?"
|
|
38
|
+
|
|
39
|
+
_ENV_PREFIX = "env:"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_env(value: Any) -> Any:
|
|
43
|
+
"""A catalog property of the form ``env:VAR`` becomes the variable's value.
|
|
44
|
+
|
|
45
|
+
Keeps secrets (catalog tokens, S3 keys, signer URIs) out of the stored
|
|
46
|
+
binding: the graph holds the *name* of the env var, never the secret. Any
|
|
47
|
+
non-string or non-prefixed value passes through unchanged.
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(value, str) and value.startswith(_ENV_PREFIX):
|
|
50
|
+
import os
|
|
51
|
+
|
|
52
|
+
name = value[len(_ENV_PREFIX) :]
|
|
53
|
+
resolved = os.environ.get(name)
|
|
54
|
+
if not resolved:
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
f"iceberg catalog property references env var {name!r}, which is "
|
|
57
|
+
f"unset — cannot reach the catalog."
|
|
58
|
+
)
|
|
59
|
+
return resolved
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_catalog_props(catalog: dict[str, Any]) -> dict[str, Any]:
|
|
64
|
+
"""Catalog config with every ``env:VAR`` value substituted from the env.
|
|
65
|
+
|
|
66
|
+
The catalog dict is pyiceberg's own property bag (``uri``, ``warehouse``,
|
|
67
|
+
``type``, ``token``, S3 creds, …) minus the reserved ``name`` key, which
|
|
68
|
+
names the catalog rather than configuring it.
|
|
69
|
+
"""
|
|
70
|
+
return {
|
|
71
|
+
k: _resolve_env(v) for k, v in catalog.items() if k != "name"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_table(catalog: dict[str, Any], table: str):
|
|
76
|
+
"""Load a pyiceberg table from a catalog config + ``namespace.table`` name."""
|
|
77
|
+
try:
|
|
78
|
+
from pyiceberg.catalog import load_catalog # type: ignore
|
|
79
|
+
except ImportError as e: # pragma: no cover - env dependent
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
"iceberg virtual edge needs the iceberg extra: pip install "
|
|
82
|
+
"'knowledge-graph-rdbms[iceberg]'"
|
|
83
|
+
) from e
|
|
84
|
+
name = catalog.get("name", "default")
|
|
85
|
+
return load_catalog(name, **resolve_catalog_props(catalog)).load_table(table)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _s3_region(catalog: dict[str, Any]) -> str:
|
|
89
|
+
"""Region for DuckDB's S3 access — catalog hint first, then the AWS env."""
|
|
90
|
+
import os
|
|
91
|
+
|
|
92
|
+
for key in ("rest.signing-region", "client.region", "region"):
|
|
93
|
+
if catalog.get(key):
|
|
94
|
+
return str(_resolve_env(catalog[key]))
|
|
95
|
+
return os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _prepare_s3(conn: Any, catalog: dict[str, Any]) -> None:
|
|
99
|
+
"""Give DuckDB what it needs to read s3:// metadata + data files.
|
|
100
|
+
|
|
101
|
+
Loads the httpfs/aws extensions and registers a credential-chain secret so
|
|
102
|
+
DuckDB authenticates with the same AWS identity as the host (env, profile,
|
|
103
|
+
instance role). This is what lets ``iceberg_scan`` reach S3 Tables' managed
|
|
104
|
+
storage and ordinary S3 warehouses alike — sigv4 signing included.
|
|
105
|
+
"""
|
|
106
|
+
conn.execute("INSTALL httpfs")
|
|
107
|
+
conn.execute("LOAD httpfs")
|
|
108
|
+
conn.execute("INSTALL aws")
|
|
109
|
+
conn.execute("LOAD aws")
|
|
110
|
+
conn.execute("INSTALL iceberg")
|
|
111
|
+
conn.execute("LOAD iceberg")
|
|
112
|
+
region = _s3_region(catalog)
|
|
113
|
+
conn.execute(
|
|
114
|
+
f"CREATE SECRET (TYPE s3, PROVIDER credential_chain, REGION '{region}')"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _view_name(table: str) -> str:
|
|
119
|
+
"""The DuckDB view name a binding's query references — the table's leaf name.
|
|
120
|
+
|
|
121
|
+
``analytics.co_held`` → ``co_held``. The binding author writes plain
|
|
122
|
+
``FROM co_held``, exactly as they would against a Postgres relation.
|
|
123
|
+
"""
|
|
124
|
+
return table.rsplit(".", 1)[-1]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def open_source(
|
|
128
|
+
catalog: dict[str, Any],
|
|
129
|
+
tables: str | list[str],
|
|
130
|
+
snapshot_id: int | None = None,
|
|
131
|
+
) -> tuple[Any, str]:
|
|
132
|
+
"""Open one or more Iceberg tables as a single queryable DuckDB connection.
|
|
133
|
+
|
|
134
|
+
Resolves each table in ``tables`` against ``catalog`` via pyiceberg and
|
|
135
|
+
registers it as a DuckDB VIEW named after its leaf segment, so the binding's
|
|
136
|
+
``query`` may JOIN across them by plain table name. Returns
|
|
137
|
+
``(connection, marker)`` shaped exactly like :func:`kgrdbms.virtual._connect`,
|
|
138
|
+
so :func:`kgrdbms.virtual.resolve` runs the parameterized query unchanged.
|
|
139
|
+
|
|
140
|
+
``snapshot_id`` pins a time-travel version; a snapshot id is table-specific,
|
|
141
|
+
so it is only valid when mounting a single table.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
import duckdb # type: ignore
|
|
145
|
+
except ImportError as e: # pragma: no cover - env dependent
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
"iceberg virtual edge needs the iceberg extra: pip install "
|
|
148
|
+
"'knowledge-graph-rdbms[iceberg]'"
|
|
149
|
+
) from e
|
|
150
|
+
|
|
151
|
+
names = [tables] if isinstance(tables, str) else list(tables)
|
|
152
|
+
if not names:
|
|
153
|
+
raise RuntimeError("iceberg open_source: no tables to mount.")
|
|
154
|
+
if snapshot_id is not None and len(names) > 1:
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
"iceberg virtual edge: `snapshot_id` pins a single table's version "
|
|
157
|
+
"and cannot be used when mounting multiple tables."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
resolved = [(_view_name(n), _load_table(catalog, n).metadata_location) for n in names]
|
|
161
|
+
|
|
162
|
+
conn = duckdb.connect()
|
|
163
|
+
# INSTALL is idempotent and cached; kept here (not at import) so importing
|
|
164
|
+
# this module never reaches the network. An s3:// warehouse (S3 Tables, Glue,
|
|
165
|
+
# or a plain S3 lake) needs httpfs/aws + a credential secret; a local file
|
|
166
|
+
# warehouse needs only the iceberg extension. One credential setup serves
|
|
167
|
+
# every view on the connection.
|
|
168
|
+
if any(str(loc).startswith("s3://") for _, loc in resolved):
|
|
169
|
+
_prepare_s3(conn, catalog)
|
|
170
|
+
else:
|
|
171
|
+
conn.execute("INSTALL iceberg")
|
|
172
|
+
conn.execute("LOAD iceberg")
|
|
173
|
+
|
|
174
|
+
for view, metadata_location in resolved:
|
|
175
|
+
# The metadata location is operator-resolved (from the catalog), not user
|
|
176
|
+
# input, so formatting it into the DDL is safe — DuckDB's scan function
|
|
177
|
+
# takes a path literal, not a bound parameter, for the table source.
|
|
178
|
+
scan = f"iceberg_scan('{metadata_location}'"
|
|
179
|
+
if snapshot_id is not None:
|
|
180
|
+
scan += f", snapshot_from_id => {int(snapshot_id)}"
|
|
181
|
+
scan += ")"
|
|
182
|
+
conn.execute(f'CREATE VIEW "{view}" AS SELECT * FROM {scan}')
|
|
183
|
+
return conn, MARKER
|
|
@@ -332,16 +332,22 @@ def kg_virtual_edge_add(
|
|
|
332
332
|
name_col: str | None = None,
|
|
333
333
|
prop_cols: list[str] | None = None,
|
|
334
334
|
directions: str = "out",
|
|
335
|
+
source_type: str = "sql",
|
|
336
|
+
catalog: dict | None = None,
|
|
337
|
+
table: str | None = None,
|
|
338
|
+
tables: list[str] | None = None,
|
|
339
|
+
snapshot_id: int | None = None,
|
|
335
340
|
ontology: str | None = None,
|
|
336
341
|
) -> dict:
|
|
337
|
-
"""Bind an edge TYPE to a
|
|
342
|
+
"""Bind an edge TYPE to a query that resolves its instances live from an
|
|
338
343
|
external source — no rows are copied into the graph (Ontology-Based Data
|
|
339
344
|
Access). At traversal time kg_edges runs `query`, parameterized by the node
|
|
340
345
|
you're standing on, and synthesizes the edges; results carry `_virtual: true`.
|
|
341
346
|
|
|
342
347
|
`query` must hold exactly one placeholder bound to the source value — the
|
|
343
|
-
driver's native marker ('?' for sqlite, '%s' for
|
|
344
|
-
always *bound*, never string-formatted, so the query
|
|
348
|
+
driver's native marker ('?' for sqlite *and* iceberg/duckdb, '%s' for
|
|
349
|
+
postgres). The value is always *bound*, never string-formatted, so the query
|
|
350
|
+
is injection-safe.
|
|
345
351
|
|
|
346
352
|
Source of the bound value (`source`): "id" (default), "id_slug" (after the
|
|
347
353
|
last ':'), or "prop:<key>" (a node property, e.g. "prop:ticker"). Each result
|
|
@@ -349,18 +355,35 @@ def kg_virtual_edge_add(
|
|
|
349
355
|
(e.g. "company:{value}") builds its node id, `name_col` names it, `prop_cols`
|
|
350
356
|
(or every other column) become edge properties. `directions` is out|in|both.
|
|
351
357
|
|
|
352
|
-
|
|
353
|
-
|
|
358
|
+
SOURCE_TYPE selects where `query` runs:
|
|
359
|
+
* "sql" (default) — a direct SQL store. Prefer `dsn_env` (an env-var *name*,
|
|
360
|
+
resolved at query time) so secrets stay out of the graph; `dsn` is the
|
|
361
|
+
literal form for non-secret paths.
|
|
362
|
+
* "iceberg" — an Apache Iceberg table in a lakehouse. Give `catalog` (a
|
|
363
|
+
pyiceberg catalog property dict, e.g. {"name","type","uri","warehouse"};
|
|
364
|
+
any value written "env:VAR" is resolved from the environment so creds stay
|
|
365
|
+
out of the graph) and `table` ("namespace.table"). pyiceberg resolves the
|
|
366
|
+
current metadata (or `snapshot_id` for a time-travel read); DuckDB scans it.
|
|
367
|
+
Write `query` against the table's leaf name, e.g.
|
|
368
|
+
"SELECT b AS to_id FROM co_held WHERE a = ?". To JOIN across tables in one
|
|
369
|
+
edge, pass `tables` (a list of "namespace.table"); each is mounted as a
|
|
370
|
+
view by its leaf name and the query may join them freely (`snapshot_id`
|
|
371
|
+
then doesn't apply — it pins a single table's version).
|
|
372
|
+
|
|
354
373
|
The binding is stored as a reserved `_VirtualEdge` node in the ontology.
|
|
355
374
|
"""
|
|
356
375
|
ve = virtual.VirtualEdge(
|
|
357
376
|
edge_type=edge_type, query=query, dsn=dsn, dsn_env=dsn_env, source=source,
|
|
358
377
|
target_col=target_col, target_id_template=target_id_template,
|
|
359
378
|
target_kind=target_kind, name_col=name_col, prop_cols=list(prop_cols or []),
|
|
360
|
-
directions=directions,
|
|
379
|
+
directions=directions, source_type=source_type, catalog=catalog,
|
|
380
|
+
table=table, tables=list(tables or []), snapshot_id=snapshot_id,
|
|
361
381
|
)
|
|
362
382
|
virtual.register(_bundle(ontology).backend, ve)
|
|
363
|
-
return {
|
|
383
|
+
return {
|
|
384
|
+
"edge_type": edge_type, "directions": directions,
|
|
385
|
+
"source_type": source_type, "bound": True,
|
|
386
|
+
}
|
|
364
387
|
|
|
365
388
|
|
|
366
389
|
@mcp.tool()
|
|
@@ -372,7 +395,9 @@ def kg_virtual_edges_list(ontology: str | None = None) -> list[dict]:
|
|
|
372
395
|
{
|
|
373
396
|
"edge_type": ve.edge_type, "directions": ve.directions, "source": ve.source,
|
|
374
397
|
"target_kind": ve.target_kind, "target_id_template": ve.target_id_template,
|
|
375
|
-
"
|
|
398
|
+
"source_type": ve.source_type, "dsn_env": ve.dsn_env, "dsn": ve.dsn,
|
|
399
|
+
"catalog": ve.catalog, "table": ve.table, "tables": list(ve.tables),
|
|
400
|
+
"snapshot_id": ve.snapshot_id, "query": ve.query,
|
|
376
401
|
}
|
|
377
402
|
for ve in virtual.list_bindings(_bundle(ontology).backend)
|
|
378
403
|
]
|
|
@@ -34,6 +34,7 @@ discoverable via the ordinary read tools.
|
|
|
34
34
|
from __future__ import annotations
|
|
35
35
|
|
|
36
36
|
import os
|
|
37
|
+
from collections.abc import Mapping
|
|
37
38
|
from dataclasses import dataclass, field
|
|
38
39
|
from typing import Any
|
|
39
40
|
|
|
@@ -88,6 +89,27 @@ class VirtualEdge:
|
|
|
88
89
|
name_col: str | None = None
|
|
89
90
|
prop_cols: list[str] = field(default_factory=list)
|
|
90
91
|
directions: str = "out"
|
|
92
|
+
# ---- source kind ----
|
|
93
|
+
# "sql" (default) resolves against dsn/dsn_env via _connect. "iceberg" resolves
|
|
94
|
+
# against an Apache Iceberg table named by `catalog` + `table` (see iceberg.py);
|
|
95
|
+
# the binding's query then references the table by its leaf name like any SQL
|
|
96
|
+
# relation. `snapshot_id` optionally pins a version for time-travel reads.
|
|
97
|
+
source_type: str = "sql"
|
|
98
|
+
catalog: dict[str, Any] | None = None
|
|
99
|
+
table: str | None = None
|
|
100
|
+
tables: list[str] = field(default_factory=list)
|
|
101
|
+
snapshot_id: int | None = None
|
|
102
|
+
|
|
103
|
+
def iceberg_tables(self) -> list[str]:
|
|
104
|
+
"""The full set of Iceberg tables to mount as DuckDB views for this
|
|
105
|
+
binding — the primary `table` plus any `tables`, de-duplicated, order
|
|
106
|
+
preserved. A binding's `query` may JOIN freely across all of them
|
|
107
|
+
(referenced by leaf name). Multiple tables let one edge stitch together,
|
|
108
|
+
say, a holdings fact table with a lookup dimension in a single query."""
|
|
109
|
+
seen: dict[str, None] = {}
|
|
110
|
+
for t in ([self.table] if self.table else []) + list(self.tables):
|
|
111
|
+
seen.setdefault(t, None)
|
|
112
|
+
return list(seen)
|
|
91
113
|
|
|
92
114
|
# ---- (de)serialization to a reserved-kind node ----
|
|
93
115
|
|
|
@@ -104,6 +126,11 @@ class VirtualEdge:
|
|
|
104
126
|
"name_col": self.name_col,
|
|
105
127
|
"prop_cols": list(self.prop_cols),
|
|
106
128
|
"directions": self.directions,
|
|
129
|
+
"source_type": self.source_type,
|
|
130
|
+
"catalog": self.catalog,
|
|
131
|
+
"table": self.table,
|
|
132
|
+
"tables": list(self.tables),
|
|
133
|
+
"snapshot_id": self.snapshot_id,
|
|
107
134
|
}
|
|
108
135
|
|
|
109
136
|
@classmethod
|
|
@@ -121,6 +148,11 @@ class VirtualEdge:
|
|
|
121
148
|
name_col=p.get("name_col"),
|
|
122
149
|
prop_cols=list(p.get("prop_cols", [])),
|
|
123
150
|
directions=p.get("directions", "out"),
|
|
151
|
+
source_type=p.get("source_type", "sql"),
|
|
152
|
+
catalog=p.get("catalog"),
|
|
153
|
+
table=p.get("table"),
|
|
154
|
+
tables=list(p.get("tables", [])),
|
|
155
|
+
snapshot_id=p.get("snapshot_id"),
|
|
124
156
|
)
|
|
125
157
|
|
|
126
158
|
def resolved_dsn(self) -> str:
|
|
@@ -204,6 +236,27 @@ def _connect(dsn: str):
|
|
|
204
236
|
return conn, "?"
|
|
205
237
|
|
|
206
238
|
|
|
239
|
+
def _open_source(ve: VirtualEdge) -> tuple[Any, str]:
|
|
240
|
+
"""Open the binding's external source, returning ``(connection, marker)``.
|
|
241
|
+
|
|
242
|
+
Dispatches on ``source_type``: an Iceberg binding goes through the lakehouse
|
|
243
|
+
opener (catalog → DuckDB view); everything else is a direct SQL connection to
|
|
244
|
+
``dsn``/``dsn_env``. Both return a DB-API connection plus its positional
|
|
245
|
+
placeholder, so the rest of :func:`resolve` is source-agnostic.
|
|
246
|
+
"""
|
|
247
|
+
if ve.source_type == "iceberg":
|
|
248
|
+
tables = ve.iceberg_tables()
|
|
249
|
+
if not ve.catalog or not tables:
|
|
250
|
+
raise RuntimeError(
|
|
251
|
+
f"virtual edge {ve.edge_type!r}: source_type='iceberg' requires "
|
|
252
|
+
f"`catalog` and at least one of `table` / `tables`."
|
|
253
|
+
)
|
|
254
|
+
from kgrdbms import iceberg
|
|
255
|
+
|
|
256
|
+
return iceberg.open_source(ve.catalog, tables, ve.snapshot_id)
|
|
257
|
+
return _connect(ve.resolved_dsn())
|
|
258
|
+
|
|
259
|
+
|
|
207
260
|
def _source_value(ve: VirtualEdge, node_id: str, node: Node | None) -> Any:
|
|
208
261
|
if ve.source == "id":
|
|
209
262
|
return node_id
|
|
@@ -237,11 +290,19 @@ def resolve(
|
|
|
237
290
|
value = _source_value(ve, node_id, node)
|
|
238
291
|
if value is None:
|
|
239
292
|
return []
|
|
240
|
-
conn, marker =
|
|
293
|
+
conn, marker = _open_source(ve)
|
|
241
294
|
try:
|
|
242
295
|
n_params = ve.query.count(marker)
|
|
243
296
|
cur = conn.execute(ve.query, tuple([value] * max(1, n_params)))
|
|
244
|
-
|
|
297
|
+
fetched = cur.fetchall()
|
|
298
|
+
# Normalize to dict rows across drivers: sqlite Row and psycopg dict_row
|
|
299
|
+
# are already mappings; duckdb yields positional tuples, so zip them with
|
|
300
|
+
# the cursor's column names.
|
|
301
|
+
cols = [d[0] for d in cur.description] if cur.description else []
|
|
302
|
+
rows = [
|
|
303
|
+
dict(r) if isinstance(r, Mapping) else dict(zip(cols, r))
|
|
304
|
+
for r in fetched
|
|
305
|
+
]
|
|
245
306
|
finally:
|
|
246
307
|
conn.close()
|
|
247
308
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "knowledge-graph-rdbms"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.8"
|
|
8
8
|
description = "A label property graph on an RDBMS (SQLite): nodes, typed edges, an append-only event log, and an optional MCP server."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -28,8 +28,9 @@ Issues = "https://github.com/cunicopia-dev/knowledge-graph-rdbms/issues"
|
|
|
28
28
|
mcp = ["mcp>=1.0"]
|
|
29
29
|
charts = ["matplotlib>=3.7"]
|
|
30
30
|
postgres = ["psycopg[binary]>=3.1"]
|
|
31
|
+
iceberg = ["duckdb>=1.1", "pyiceberg[sql-sqlite,glue,s3fs]>=0.7", "pyarrow>=14", "boto3>=1.34"]
|
|
31
32
|
rdf = ["rdflib>=7.0"] # only needed for parsing/SPARQL on import; export is dep-free
|
|
32
|
-
dev = ["pytest>=7.0", "mcp>=1.0", "psycopg[binary]>=3.1", "rdflib>=7.0"]
|
|
33
|
+
dev = ["pytest>=7.0", "mcp>=1.0", "psycopg[binary]>=3.1", "rdflib>=7.0", "duckdb>=1.1", "pyiceberg[sql-sqlite]>=0.7", "pyarrow>=14"]
|
|
33
34
|
|
|
34
35
|
[project.scripts]
|
|
35
36
|
kg = "kgrdbms.cli:main"
|