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.
Files changed (61) hide show
  1. knowledge_graph_rdbms-0.1.7/README.md → knowledge_graph_rdbms-0.1.8/PKG-INFO +116 -0
  2. knowledge_graph_rdbms-0.1.7/PKG-INFO → knowledge_graph_rdbms-0.1.8/README.md +77 -31
  3. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/__init__.py +1 -1
  4. knowledge_graph_rdbms-0.1.8/kgrdbms/iceberg.py +183 -0
  5. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/mcp_server.py +33 -8
  6. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/virtual.py +63 -2
  7. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/pyproject.toml +3 -2
  8. knowledge_graph_rdbms-0.1.8/tests/test_iceberg.py +289 -0
  9. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/uv.lock +1637 -2
  10. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/.claude/skills/kg-compose/SKILL.md +0 -0
  11. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/.github/workflows/ci.yml +0 -0
  12. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/.github/workflows/publish.yml +0 -0
  13. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/.gitignore +0 -0
  14. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/CLAUDE.md +0 -0
  15. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/CODE_OF_CONDUCT.md +0 -0
  16. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/CONTRIBUTING.md +0 -0
  17. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/LICENSE +0 -0
  18. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/SECURITY.md +0 -0
  19. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/assets/crossover.png +0 -0
  20. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/assets/read_latency.png +0 -0
  21. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/assets/runtimes.png +0 -0
  22. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/assets/write_throughput.png +0 -0
  23. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/README.md +0 -0
  24. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/benchmark.py +0 -0
  25. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/charts.py +0 -0
  26. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/neo4j/README.md +0 -0
  27. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/neo4j/headtohead.py +0 -0
  28. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/postgres/README.md +0 -0
  29. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/postgres/benchmark.py +0 -0
  30. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/postgres/charts.py +0 -0
  31. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/runtimes/compare.py +0 -0
  32. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/runtimes/run_bun.js +0 -0
  33. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/runtimes/run_node.mjs +0 -0
  34. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/bench/runtimes/run_python.py +0 -0
  35. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backbone.py +0 -0
  36. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backends/__init__.py +0 -0
  37. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backends/base.py +0 -0
  38. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backends/neo4j.py +0 -0
  39. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backends/postgres.py +0 -0
  40. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/backends/sqlite.py +0 -0
  41. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/cli.py +0 -0
  42. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/events.py +0 -0
  43. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/federation.py +0 -0
  44. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/graph.py +0 -0
  45. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/invariants.py +0 -0
  46. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/policy.py +0 -0
  47. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/rdf.py +0 -0
  48. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/resolver.py +0 -0
  49. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/kgrdbms/service.py +0 -0
  50. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_backbone.py +0 -0
  51. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_bulk.py +0 -0
  52. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_cli.py +0 -0
  53. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_events.py +0 -0
  54. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_federation.py +0 -0
  55. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_graph.py +0 -0
  56. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_mcp_server.py +0 -0
  57. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_policy.py +0 -0
  58. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_postgres.py +0 -0
  59. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_rdf.py +0 -0
  60. {knowledge_graph_rdbms-0.1.7 → knowledge_graph_rdbms-0.1.8}/tests/test_schema.py +0 -0
  61. {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
  ![PyPI](https://img.shields.io/pypi/v/knowledge-graph-rdbms?logo=pypi&logoColor=white&color=3775A9)
@@ -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
  ![PyPI](https://img.shields.io/pypi/v/knowledge-graph-rdbms?logo=pypi&logoColor=white&color=3775A9)
@@ -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.5"
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 SQL query that resolves its instances live from an
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 postgres). The value is
344
- always *bound*, never string-formatted, so the query is injection-safe.
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
- Credentials: prefer `dsn_env` (an env-var *name*, resolved at query time) so
353
- secrets stay out of the graph; `dsn` is the literal form for non-secret paths.
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 {"edge_type": edge_type, "directions": directions, "bound": True}
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
- "dsn_env": ve.dsn_env, "dsn": ve.dsn, "query": ve.query,
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 = _connect(ve.resolved_dsn())
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
- rows = [dict(r) for r in cur.fetchall()]
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"
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"