Adding a New Graph DB Engine
This guide walks a developer through adding support for a new graph database engine to OntoBricks. It covers the architecture, the abstract contracts,…
This guide walks a developer through adding support for a new graph database engine to OntoBricks. It covers the architecture, the abstract contracts, registration in the factory and global config, and a ready-to-use starter kit.
OntoBricks ships three runtime graph engines. The backend is chosen per domain under Domain → Information → Knowledge Graph (mandatory; defaults to lakebase) — the selection is stored in DomainSession.info['graph_backend']. Engine connection config stays workspace-global under Settings → Back end.
| Engine | Storage | Notes |
|---|---|---|
lakebase (default) |
Flat triple tables on Lakebase Postgres | Uses the App-bound Postgres instance (PGHOST / PGDATABASE…). Configure graph_engine_config.lakebase with optional database (Postgres DB name on that instance) and schema (default ontobricks_graph), and sync_mode (app_managed or managed_synced). SQL-only (no Cypher); reasoning uses the existing SQL translators. |
databricks (Delta) |
Unity Catalog Delta triple tables | Configure graph_engine_config.lakehouse.warehouse_id. |
neo4j |
Native graph over Bolt | Neo4j Aura or self-hosted; connection config in graph_engine_config.neo4j (uri, database, credentials). |
Each backend's connection settings are stored in separate buckets under graph_engine_config (lakebase / neo4j / lakehouse) so an admin can configure all backends independently without shared keys. Flat legacy blobs are migrated on read and rewritten nested on the next Save.
GraphDBFactory.create(engine=...) is the single decision point: only the selected engine is instantiated. The capability flags on GraphDBBackend (supports_cypher, is_cypher_backend, query_dialect) are kept as architectural seams so a future Cypher / Gremlin engine can be added without rewiring reasoning.
1. Architecture Overview
OntoBricks stores all graph viewer data through a single abstraction:
| Layer | Package | Purpose |
|---|---|---|
| Graph DB | back.core.graphdb |
The single triple store / graph DB layer. Ships Lakebase Postgres and Unity Catalog Delta engines; pluggable for embedded/Cypher engines for traversal, reasoning, and analytics. |
The single GraphDBFactory reads the per-domain backend choice from DomainSession.info['graph_backend'] and constructs the matching backend. Calling get_graphdb(domain, settings) with no engine auto-resolves; passing engine="view" returns a raw read-only Delta store for health probes.
get_graphdb(domain, settings) # engine=None → auto-resolve
│
└─ GraphDBFactory.create(engine=None)
│
├─ _resolve_graph_backend() → domain.info["graph_backend"]
│ "lakebase" | "databricks" | "neo4j"
├─ _resolve_triple_store_backend() → "lakebase" | "databricks"
├─ _resolve_graph_engine() → "lakebase" | "neo4j"
└─ GraphDBFactory.create(domain, settings, engine="neo4j")
│
└─ _create_neo4j(domain, settings) → Neo4jStore(...)
Key files
| File | Role |
|---|---|
src/back/core/graphdb/GraphDBBackend.py |
The single abstract base — triple CRUD, named query + reasoning methods (SQL defaults), capability flags, connection management, sync. |
src/back/core/graphdb/GraphDBFactory.py |
Factory — engine resolution + maps engine names to constructor methods. |
src/back/core/graphdb/constants.py |
Shared RDF constants (RDF_TYPE, RDFS_LABEL). |
src/back/core/graphdb/delta/DeltaFlatStore.py |
Unity Catalog Delta engine (also the raw view store). |
src/back/core/graphdb/delta/_table_naming.py |
FQN helpers for the R2RML VIEW, _data, _inferred, _graph. |
src/back/core/graphdb/delta/materialize.py |
CTAS / companion / union-VIEW SQL used by Build. |
src/back/core/graphdb/__init__.py |
Package exports (get_graphdb, GRAPHDB_AVAILABLE). |
src/back/objects/session/GlobalConfigService.py |
Persists the engine connection config (graph_engine_config) in .global_config.json. The backend selection lives per-domain in DomainSession.info['graph_backend']. |
Lakehouse (Delta) Unity Catalog objects
Regardless of which Graph DB engine the domain uses, Knowledge Graph → Build always materialises a small family of Unity Catalog objects in the registry catalog.schema. They share the base name triplestore_<safe_domain>_V<version>:
| Suffix / name | Kind | Created by | Consumed by |
|---|---|---|---|
(no suffix) triplestore_<domain>_V<n> |
VIEW | Build — CREATE OR REPLACE VIEW from the R2RML SQL |
Source for the _data CTAS; governance / lineage of the mapping |
_data |
Delta TABLE | Build — CREATE OR REPLACE TABLE … AS SELECT … FROM <view>, CLUSTER BY (predicate, subject) |
Graph Analytics (Lakeflow job); bulk half of _graph; Lakehouse engine reads |
_inferred |
Delta TABLE | Build — CREATE TABLE IF NOT EXISTS (same SPO shape); truncated on full rebuild |
Reasoning / cohort / app writes |
_graph |
VIEW | Build — _data UNION ALL _inferred |
Explorer, filters, stats, GraphQL when inferred triples are included |
source tables
→ R2RML VIEW (live mapping)
→ _data TABLE (mapped snapshot)
↘
_graph VIEW (interactive graph reads)
↗
_inferred TABLE (app-written triples)
The mapped snapshot (_data) is what makes KPIs identical across backends: Lakebase and Neo4j mirror those triples into their own stores, but analytics always scores _data, never the engine-local copy and never _inferred. If _data is missing (typical for domains last built before the materialise step was unconditional), analytics refuses the run and tells the user to rebuild — it must not be reported as a warehouse connectivity failure.
Canonical prose also lives in architecture.md § Lakehouse Unity Catalog objects.
2. The Contract
A new engine implements the single GraphDBBackend abstraction:
2.1 GraphDBBackend (core CRUD)
These abstract methods must be implemented:
| Method | Signature | Description |
|---|---|---|
create_table |
(table_name: str) -> None |
Create the (subject, predicate, object) storage. |
drop_table |
(table_name: str) -> None |
Drop the table if it exists. |
insert_triples |
(table_name, triples, batch_size, on_progress) -> int |
Batch insert triples. Return count inserted. |
query_triples |
(table_name: str) -> List[Dict[str, str]] |
Return all triples as {subject, predicate, object} dicts. |
count_triples |
(table_name: str) -> int |
Return the number of triples. |
table_exists |
(table_name: str) -> bool |
Check if the triple table exists. |
get_status |
(table_name: str) -> Dict[str, Any] |
Return {count, last_modified, path, format}. |
execute_query |
(query: str) -> List[Dict[str, Any]] |
Execute a raw query (SQL or native). Raise NotImplementedError if not applicable. |
These methods have SQL default implementations that you should override if your engine does not speak SQL:
get_aggregate_statsget_type_distribution/get_predicate_distributionfind_subjects_by_type/resolve_subject_by_idget_entity_metadata/get_triples_for_subjectsget_predicates_for_typepaginated_triples/paginated_countbfs_traversalfind_seed_subjects/find_subjects_by_patternstransitive_closure/symmetric_expand/shortest_pathexpand_entity_neighborsdelete_triples(raisesNotImplementedErrorby default)optimize_table(no-op by default)
2.2 GraphDBBackend (graph-specific methods)
Constructor parameter — every engine receives engine_config: Dict[str, Any] (default {}) from the factory. This is a free-form JSON dict set by the admin in Settings > Graph DB > Engine Configuration. Each engine defines its own keys. For Lakebase, recognised keys include database, schema, and mode (app_managed or managed_synced).
These abstract methods must be implemented:
| Method | Signature | Description |
|---|---|---|
get_connection |
() -> Any |
Return (and lazily open) the native database connection. |
close |
() -> None |
Release the connection and any related resources. |
These have sensible defaults that you should override as needed:
| Method | Default | Override when... |
|---|---|---|
supports_cypher |
False |
Your engine speaks Cypher. |
supports_graph_model |
False |
Your engine uses typed node/relationship tables. |
query_dialect |
"sql" |
Your engine uses a different dialect (e.g. "cypher", "gremlin"). |
get_node_table(name) |
Returns name unchanged |
Your engine has naming constraints (e.g. identifier sanitisation). |
get_graph_schema() |
None |
Your engine builds a graph schema from the ontology. |
sync_to_remote(uc_path, volume_service) |
No-op | Your engine stores files that should be synced to UC Volumes. |
sync_from_remote(uc_path, volume_service) |
No-op | Same, for restore on cold start. |
local_path() |
None |
Your engine stores data locally. |
remote_archive_path(uc_domain_path) |
None |
Your engine has a remote archive naming convention. |
get_query_translator(table_name) |
SWRLSQLTranslator() |
Your engine needs a custom SWRL/rule translator for reasoning. |
3. Step-by-Step Integration
Step 1 — Create the engine subpackage
src/back/core/graphdb/
├── __init__.py
├── GraphDBBackend.py
├── GraphDBFactory.py
├── lakebase/ ← existing (Postgres flat-store reference impl)
├── _starter_kit/ ← copy-paste template (ExampleStore.py)
└── kuzu/ ← NEW
├── __init__.py
└── KuzuStore.py
Per coding rules: one public class per file, file named after the class in PascalCase.
Step 2 — Implement the store class
Create src/back/core/graphdb/kuzu/KuzuStore.py. Copy it from the starter kit at src/back/core/graphdb/_starter_kit/ExampleStore.py and rename. See Section 5 for details.
Key decisions:
Query dialect: If your engine speaks Cypher, set
supports_cypher = Trueandquery_dialect = "cypher". Override the named query methods with native Cypher implementations and ship a matchingSWRLCypherTranslator(the SQL translator stays the default for SQL engines).Graph model: If your engine uses typed node/relationship tables, set
supports_graph_model = Trueand implementget_graph_schema(). If it uses a flat triple table (like the shippedLakebaseFlatStore), leave itFalse.Reasoning translator: Return the appropriate
SWRL*Translatorfromget_query_translator(). For SQL engines, the defaultSWRLSQLTranslatorworks.Sync: If your engine stores data as local files, implement
sync_to_remote()andsync_from_remote()to archive/restore viaVolumeFileService. Lakebase does not need this — the data lives in Postgres.
Step 3 — Create the package __init__.py
# src/back/core/graphdb/kuzu/__init__.py
"""KuzuDB graph database backend."""
from back.core.graphdb.kuzu.KuzuStore import KuzuStore # noqa: F401
__all__ = ["KuzuStore"]Step 4 — Register the engine in GraphDBFactory
Edit src/back/core/graphdb/GraphDBFactory.py:
def create(self, domain, settings=None, engine=None, engine_config=None):
if engine is None:
engine = "lakebase"
if engine_config is None:
engine_config = {}
if engine == "lakebase":
return self._create_lakebase(domain, settings, engine_config=engine_config)
if engine == "kuzu": # ← NEW
return self._create_kuzu(domain, settings, engine_config=engine_config)
logger.warning("Unknown graph DB engine: %s", engine)
return None
def _create_kuzu(self, domain, settings=None, *, engine_config=None): # ← NEW
"""Instantiate a KuzuDB store."""
try:
from back.core.graphdb.kuzu.KuzuStore import KuzuStore
base_name = (domain.info or {}).get("name", DEFAULT_GRAPH_NAME)
version = getattr(domain, 'current_version', '1') or '1'
db_name = f"{base_name}_V{version}"
return KuzuStore(db_name=db_name, engine_config=engine_config)
except ImportError as e:
logger.warning("KuzuDB requires kuzu: %s", e)
return None
except Exception as e:
logger.exception("Failed to create KuzuStore: %s", e)
return None
engine_configis a free-form JSON dict set by the admin in Settings > Graph DB > Engine Configuration. The factory reads it fromGlobalConfigServiceand passes it to every engine constructor. Each engine defines its own keys (e.g.host,port,credentials_path). For Lakebase, recognised keys aredatabase,schema, andmode.
Then update the availability check at the bottom of the file:
try:
from back.core.graphdb.kuzu.KuzuStore import KuzuStore # noqa: F401
GraphDBFactory.KUZU_AVAILABLE = True
except ImportError:
GraphDBFactory.KUZU_AVAILABLE = FalseStep 5 — Register the engine in the per-domain backend vocabulary
The backend selection is per-domain. Add your engine to the unified vocabulary + mapping in src/back/core/graphdb/GraphDBFactory.py:
GRAPH_BACKENDS = ("lakebase", "databricks", "neo4j", "kuzu") # ← add hereThen map it inside _resolve_triple_store_backend / _resolve_graph_engine so graph_backend == "kuzu" resolves to your engine.
Step 6 — Add the option to the per-domain dropdown
Edit src/front/templates/partials/domain/_domain_information.html — add an <option> to the #domainGraphBackend select in the Knowledge Graph tab:
<select class="form-select domain-editable" id="domainGraphBackend" ...>
<option value="lakebase">Lakebase (Postgres)</option>
<option value="databricks">Lakehouse</option>
<option value="neo4j">Neo4j</option>
<option value="kuzu">KuzuDB</option> <!-- NEW -->
</select>If the engine needs global connection config, add its section to src/front/templates/settings.html (Settings → Back end) and persist it via graph_engine_config.
Step 7 — Add the dependency
Add the engine's Python package to pyproject.toml as an optional dependency:
[project.optional-dependencies]
kuzu = ["kuzu>=0.4"]Update docs/development.md with the new dependency (name, link, license).
Step 8 — Add tests
Create tests/test_kuzu_store.py following the patterns in tests/test_lakebase_flat_store.py. At minimum, test:
- Store instantiation (with and without the library installed)
create_table/drop_tableinsert_triples/query_triples/count_triplestable_exists/get_status- Capability flags (
supports_cypher,query_dialect)
Step 9 — Update documentation
- Update this file if the architecture changes.
- Add an entry to
docs/development.mdin the Dependencies section. - Add a Sphinx
.rstfile underdocs/sphinx/api/for the new subpackage. - Update the changelog.
4. Reference: Lakebase Engine Structure
The built-in Lakebase Postgres engine is the reference implementation:
graphdb/lakebase/
├── __init__.py ← re-exports
├── LakebaseBase.py ← GraphDBBackend subclass (connection pool, capabilities)
├── LakebaseFlatStore.py ← Flat triple table (subject, predicate, object) on Postgres
├── SyncedTableManager.py ← Lakeflow synced-table orchestration (managed_synced mode)
└── models.py ← Internal dataclasses
The flat store keeps the contract simple: a single Postgres table per (domain, version) with a primary key on (subject, predicate, object) and two write modes (app_managed via COPY FROM STDIN, managed_synced via Lakeflow). A simpler engine can use a single store class and skip SyncedTableManager.
5. Starter Kit
A ready-to-use starter kit lives at:
src/back/core/graphdb/_starter_kit/
├── README.md ← usage instructions
├── __init__.py ← package re-exports (template)
└── ExampleStore.py ← full store class with every method stubbed
How to use
Copy the
_starter_kit/directory into a new subpackage:cp -r src/back/core/graphdb/_starter_kit src/back/core/graphdb/kuzuRename
ExampleStore.pytoKuzuStore.py(matching your engine class).Find and replace these placeholders throughout the copied files:
Placeholder Replace with Example ExampleStoreYour class name KuzuStoreexample_storeYour module name (snake_case) kuzu_storeexampleYour engine identifier (lowercase) kuzuExampleYour engine display name Kuzuexample_libraryThe Python package to import kuzuFill in every
TODOmarker with your engine's native API calls.Continue from Step 3 above to register the engine in the factory, global config, and UI.
The ExampleStore.py template contains the full method contract with detailed docstrings, grouped into sections:
- Capability flags (
supports_cypher,query_dialect, …) - Connection management (
get_connection,close) - Schema helpers (
get_node_table,get_graph_schema) - Sync to/from UC Volume (
sync_to_remote,sync_from_remote) - Reasoning support (
get_query_translator) - Core CRUD (
create_table,insert_triples,query_triples, …) - Named query overrides (commented stubs for non-SQL engines)
6. Checklist
Use this checklist to track your progress:
7. FAQ
Q: Can I support both flat and graph models? Yes. Create a base class extending GraphDBBackend, then two subclasses (flat and graph). Register the graph variant in the factory and have it fall back to flat when the ontology is not available.
Q: What if my engine is remote (e.g. Neo4j Aura)? The architecture supports it. get_connection() can return a driver connected to a remote endpoint. sync_to_remote / sync_from_remote may be no-ops if data is already remote. local_path() should return None.
Q: What about the reasoning engines? Reasoning engines use GraphDBBackend.is_cypher_backend(store) and the capability flags to decide which translator to use. If your engine speaks Cypher, set the flag and return the appropriate translator from get_query_translator(). If SQL, the defaults work.
Q: Which factory do I edit? Only GraphDBFactory. It reads the engine from GlobalConfigService and dispatches to the matching _create_<engine> constructor.
8. Lakebase build performance
When the active engine is Lakebase, the Knowledge Graph build keeps heavy data on the Databricks side and never holds the full triple set inside the FastAPI process.
Read side (Databricks SQL → app)
SQLWarehouse.iter_rows(query, batch_size=5000) opens a cursor on the warehouse and yields dict rows in fetchmany batches. The build pipeline uses it for the full rebuild (SELECT subject, predicate, object FROM view) without ever materializing the full triple set inside the FastAPI process.
Write side (app → Lakebase Postgres)
LakebaseFlatStore exposes two streaming bulk paths used by the pipeline:
bulk_insert_iter(table, triple_iter, batch_size=5000)— per batch:CREATE TEMP TABLE _ob_copy_stage … ON COMMIT DROP,COPY FROM STDIN(binary), thenINSERT INTO {phy} … SELECT FROM _ob_copy_stage ON CONFLICT DO NOTHING. The temp table lives only inside the per-batch transaction (conn.transaction()is needed because the pool runsautocommit=True).bulk_delete_iter(table, triple_iter, batch_size=5000)— symmetricalCOPYinto_ob_del_stagefollowed byDELETE FROM {phy} USING _ob_del_stage d WHERE ….
insert_triples / delete_triples keep their public signatures and delegate to the bulk iterator paths once the payload crosses _BULK_INSERT_THRESHOLD / _BULK_DELETE_THRESHOLD (50 rows).
Pipeline gating
_BuildPipeline._stream_triples_into_store and _stream_triples_out_of_store call bulk_insert_iter / bulk_delete_iter when the store exposes them (Lakebase) and fall back to materializing the iterator into a list for backends without a streaming write path. _start_background_archive is a no-op for SQL-backed engines: the Delta view + Postgres tables are the system of record, no archive is pushed to the Volume.
9. Lakebase managed-synced mode (data plane only)
The default Lakebase mode (sync_mode = "app_managed") still flows R2RML triples through the FastAPI process via iter_rows + COPY FROM STDIN. Bounded memory, but the app is on the hot path.
sync_mode = "managed_synced" moves the bulk movement out of the app entirely: a Databricks Lakeflow snapshot pipeline keeps a Postgres synced table in lock-step with the R2RML view, and the app only orchestrates. Reasoning + cohort writes (small volumes) keep their direct PG path through a writable companion table; readers see both via a UNION view with the legacy table name, so SPARQL / KG search code is unchanged.
Postgres layout per graph version
| Object | Owner | Purpose |
|---|---|---|
g_<dom>_v<n>_sync |
Lakeflow (read-only) | Mirrors the source view via snapshot. |
g_<dom>_v<n>__app |
App (read/write) | Reasoning + cohort triples (datatype/lang aware). |
g_<dom>_v<n> |
App DDL (CREATE OR REPLACE VIEW) |
UNION view readers query (back-compat name). |
The synced side is restricted to (subject, predicate, object) — the union view NULL-pads datatype / lang for those rows so the view exposes a uniform 5-column shape.
Configuration
graph_engine_config accepts the following extra keys (all optional):
{
"schema": "ontobricks_graph", // fallback PG schema only when Registry has no Volume schema
"database": "appdb", // PG database (overrides PGDATABASE)
"sync_mode": "managed_synced", // default: "app_managed"
"sync_table_mode": "snapshot", // snapshot | triggered | continuous
"sync_timeout_s": 600, // wait deadline for a sync run
"sync_uc_catalog": "main" // UC catalog for synced table registration (optional override)
}
Sync UC naming is <sync_uc_catalog or fallback>.<schema>.<table> where schema is resolved by resolve_lakebase_graph_schema: Registry Volume schema (RegistryCfg.schema) always wins when Settings → Registry resolves to a non-empty triplet; otherwise graph_engine_config.schema (default ontobricks_graph). Together with catalog fallback from the same Registry, managed-synced tables register under the same catalog.schema as the Volume.
Unity Catalog Explorer — graph triples + synced table
Open Catalog Explorer at <catalog>.<registry_volume_schema>: graph triple tables, companion, union view, and the UC synced-table registration share that schema segment once the store is constructed (see build log Managed-sync registers UC synced table at …).
validate_engine_config_keys enforces the type and value constraints.
Build pipeline branch
_BuildPipeline._apply_via_synced_pipeline(full=...) replaces the row-level ingest in synced mode:
- Resolve the synced UC FQN as
<catalog>.<schema>.<base>_syncwhere catalog is:graph_engine_config.sync_uc_catalogif set; otherwiseresolve_sync_uc_fallback_catalog— optional deployment envONTOBRICKS_SYNC_UC_CATALOG(legacyONTBRICKS_*still honoured), then Settings → Registry UC catalog, thendomain.delta.catalog(per-domain Delta catalog). This avoids registering the synced table under a personal/home UC catalog when the registry triplet points at the team catalog. CREATE SCHEMA IF NOT EXISTSfor that Unity Catalogcatalog.schema(SQL warehouse DDL). The synced-table API requires this metastore object; Postgres schema alone on Lakebase is not enough. See_sync_uc_schema.ensure_uc_schema_for_synced_table_fqn.SyncedTableManager.ensure(...)-- idempotentWorkspaceClient.database.create_synced_database_tablecall.LakebaseFlatStore.ensure_synced_companion(name)— companion table only (must run before Lakeflow materializes the_synctable).SyncedTableManager.trigger_and_wait(...)— callstrigger_refresh(pipelines.start_updatewithfull_refresh=True), then waits on the returned update id viapipelines.get_updateuntil that Lakeflow run finishes (so we do not mistake a staleONLINEsynced-table status for the new build). Ifstart_updatewas skipped because another update was already active, it falls back towait_get_pipeline_idleplus synced-table polling.LakebaseFlatStore.ensure_synced_union_view(name)— union view after the_synctable exists in Postgres (CREATE OR REPLACE VIEWreferences the synced table).- On full rebuild,
TRUNCATEthe companion so reasoning + cohort start from a clean slate.
_compute_diff_or_fall_through short-circuits to actual_mode = "full" in synced mode -- snapshot pipelines always rewrite the table, so a row-level diff is wasted work. _refresh_snapshot is also skipped (Lakeflow is the truth).
The scheduler mirrors this logic via _apply_synced_pipeline in back/objects/registry/scheduler.py.
Read paths
LakebaseFlatStore separates the resolvers:
_writable_table_id(name)-- companion in synced mode, legacy phy in app-managed mode (used byinsert_triples, COPY insert, COPY delete,delete_triples)._readable_table_id(name)-- union view in synced mode (same identifier as the legacy phy in app-managed mode), used byquery_triples,iter_triples,count_triples,table_exists,get_status.optimize_tablevacuums only the writable companion in synced mode (the synced side is Lakeflow-managed).
Lifecycle
LakebaseFlatStore.drop_table(name) cascades in synced mode:
DROP VIEW IF EXISTSfor the union view.DROP TABLE IF EXISTSfor the companion.SyncedTableManager.delete(uc_name, purge_data=True)to remove the synced table from UC and its underlying PG table.
If the SDK or UC catalog is unavailable, the cascade still drops the PG view + companion and logs a warning rather than aborting.