Skip to content

SQL graph sources: R2RML over Trino-protocol endpoints, plus the fluree-sql-bridge sidecar - #1749

Open
bplatz wants to merge 15 commits into
mainfrom
feature/sql-graph-sources
Open

SQL graph sources: R2RML over Trino-protocol endpoints, plus the fluree-sql-bridge sidecar#1749
bplatz wants to merge 15 commits into
mainfrom
feature/sql-graph-sources

Conversation

@bplatz

@bplatz bplatz commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Adds SQL graph sources: R2RML mappings over tables reached through any HTTP endpoint speaking the Trino client protocol — Trino / Starburst / PrestoDB directly, or the new fluree-sql-bridge sidecar in front of a single Postgres, MySQL or SQLite database. No JDBC and no database drivers in the fluree binary; every scan is a stateless POST /v1/statement + paged GETs, so this works from a long-running server and from a Lambda alike.

#1748 has merged, so this PR is now based on main and CI runs on it. The stacking note that used to live here is resolved: main is merged in, the Cargo feature-list overlap is settled, and the graph_source/cache.rs cache-type alias main introduced is adopted.

Design

The engine's R2RML boundary already asks providers for one table at a time (scan_table(projection, filters, topk)), so a SQL source renders exactly one SELECT … WHERE … per triples map and everything else — joins, OPTIONAL, UNION, aggregation — stays in-engine. No Ontop-style whole-query rewriting.

  • Typed pushdown: every filter literal is rendered against the column type from a cached SELECT * … LIMIT 0 probe; an unsafe pairing (string vs bigint, tz-mismatched timestamp, NaN) is declined rather than guessed, since a mistyped comparison fails the whole statement in Trino and the in-engine FILTER stays authoritative regardless. Bound subjects push through template reversal; COUNT is answered by an exact SELECT COUNT(*) … IS NOT NULL (exact where Iceberg can only use manifest stats).
  • Top-k is deliberately not pushed: a NULL in a key/required column would consume LIMIT slots for rows the mapping drops, breaking the superset contract.
  • rr:sqlQuery is supported (SQL sources only): it compiles to a deterministic alias that stands in wherever a table name is expected, and scans as a derived table. Iceberg-backed registration refuses it up front.
  • No snapshots: SQL sources read live tables. Time travel, materialization and iceberg track are refused with an error naming the source (full-rebuild materialization is a follow-up). Build watermarks record sql://endpoint/table@time.
  • SSRF posture: outbound requests follow no redirects and refuse the link-local/metadata range at both the request boundary and DNS resolution — but loopback/private hosts are allowed, because a sidecar next to the database is the primary deployment shape (mirrors the S3 endpoint policy, not the catalog policy). The route is admin-token protected like /iceberg/map.

Surface

  • fluree-db-sql crate (feature sql, on by default in server/CLI; stacks on iceberg since dispatch lives in FlureeR2rmlProvider and it reuses the shared ConfigValue/auth/secret-ref machinery)
  • GraphSourceType::Sql / f:SqlMapping; Fluree::create_sql_graph_source; POST /sql/map; fluree sql map|list|info|drop (list/info/drop shared with fluree iceberg); hard drop sweeps the CAS mapping blob for all mapped-source families
  • fluree-sql-bridge/: a standalone workspace (excluded like testsuite-sparql, own lockfile and CI job) so sqlx and its three drivers never enter the main dependency tree
  • Docs: graph-sources/sql.md, cli/sql.md, endpoints, vocabulary, crate map — plus a fix to the long-broken GRAPH-join example in graph-sources/overview.md (object-with-"graph"-key syntax never parsed; the array form + from-named is now shown and test-pinned)

Verification

  • Real Trino 483 (docker, memory connector): registration probe, scans, pushed WHERE, COUNT(*), dates, zoned timestamps — all through the public API. Confirmed on the raw wire that decimal(10, 2) (with space) and … UTC renderings decode.
  • Postgres 16 through the bridge: live round-trip incl. NUMERIC, TIMESTAMPTZ (offset normalized to UTC), TEXT[].
  • The live test stays in-tree, env-gated (FLUREE_SQL_BRIDGE_URL), and skips loudly when unset.
  • MySQL 8 and Postgres 16 as CI service containers on the sql-bridge job, covering the injection regression, the LIMIT 0 probe's Trino type names and value round-trips. A guard test fails if CI ever stops supplying the URLs, so a skipped backend cannot read as a passing one.
  • Fake-endpoint e2e asserts the exact SQL sent (projection, typed predicates, derived table). Bridge has in-process protocol tests over a real SQLite file (paging, zero-row probe, errors, auth, cancel).
  • Ledger⇄SQL joins tested in both SPARQL (FROM NAMED + GRAPH) and JSON-LD (["graph", …] + "from-named").
  • Perf on existing paths: plain ledger queries untouched; the one regression risk found in self-review (a per-scan nameservice lookup added to Iceberg scans for dispatch) is removed by memoizing the decision per query session.

Known gaps / candidate follow-up issues (none filed yet)

  1. Bridge distribution: build-from-source only (no dist target / container image).
  2. Full-rebuild materialization for SQL sources.
  3. Mapping generation from a SQL schema (/iceberg/r2rml/generate is Iceberg-only).
  4. Accepting {"secret_ref": …} through POST /sql/map and fluree sql map; both store credentials as literals in the graph-source record today (documented, matches the Iceberg REST catalog).
  5. Pre-existing, noticed while testing: fromNamed dataset-local aliases don't resolve to graph sources, and the Iceberg path re-runs its nameservice lookup per scan (same memoization opportunity as fixed here for dispatch).

Happy to file any of these if you agree they're worth tracking.

Review follow-ups (@aaj3f)

  • Blocking — backslash escaping / MySQL injection: fixed at both ends. The bridge now sets NO_BACKSLASH_ESCAPES on every MySQL session, and the renderer declines the pushdown for a MySQL string carrying a backslash rather than escaping for a sql_mode it cannot observe — dialect names the database behind an endpoint that need not be a bridge we configured. Declining is the same valve type mismatches already use, so it costs I/O and not correctness. Regression tests at both layers; the render-side test was verified to fail with the guard reverted.
  • [lints] workspace = true: added. It was not clean underneath — three findings, fixed. The bridge is a separate workspace and cannot inherit, so it mirrors the table with a pointer to the root.
  • Secrets as literals: documented, and filed above as a follow-up. Correction to the review thread: the CLI stores literals too, not just the route.
  • row_count cost: documented next to the COUNT claim.
  • Found while fixing the above: Postgres had the same exposure through a settable standard_conforming_strings; the bridge now pins it per session as well.

bplatz added 9 commits August 30, 2026 10:59
…raph sources

A SQL graph source scans tables through any endpoint speaking the Trino
client protocol (POST /v1/statement + nextUri pages): Trino, Starburst,
PrestoDB, or a sidecar in front of another engine. Every page is one plain
HTTP request, so nothing is stateful on our side and no database driver is
linked into the binary.

The engine's R2RML operator asks a provider for one table at a time —
projection, conjunctive filters, optional top-k — so this crate renders
exactly one SELECT per scan. Filters are pushed typed against a cached
LIMIT 0 schema probe; a literal that cannot be rendered safely for the
column's type is declined rather than guessed, since a mistyped comparison
fails the whole statement in Trino and the in-engine FILTER stays the
authority regardless. Top-k is not pushed: a null in a key or required
column would consume LIMIT slots and break the superset contract.

Type decoding covers Trino's JSON renderings (dates, precision timestamps,
numeric-offset zones, exact decimals, base64 varbinary, NaN/Infinity).
timestamp-with-time-zone columns are selected AT TIME ZONE 'UTC' so a
named-region zone never reaches the decoder.

The endpoint guard blocks only the link-local/metadata range and follows
no redirects: loopback/private hosts are the sidecar deployment shape.
Adds GraphSourceType::Sql (f:SqlMapping) and routes it through
FlureeR2rmlProvider: has_r2rml_mapping / compiled_mapping read the mapping
reference per source family, and scan_table / table_row_count dispatch to a
Trino-protocol client when the record is SQL-backed. The COUNT shortcut is
exact for SQL (SELECT COUNT(*) WHERE key IS NOT NULL), where Iceberg can
only answer it from manifest stats.

Fluree::create_sql_graph_source mirrors the R2RML registration path: the
mapping is compiled and stored in CAS, the endpoint is probed with SELECT 1
(a failure is logged, not fatal), and the record is published. Clients are
cached process-wide keyed by the raw-config fingerprint, so a rotated
secret behind an env var does not rebuild the client every query.

A SQL source has no snapshot to pin. Its build watermark records
endpoint, table and first-touch time, and the loadTable-cache precondition
in verify_build_snapshot_integrity is skipped for it.

The end-to-end test drives registration, a plain scan, a pushed typed
equality, date decoding and the count shortcut against a fake endpoint and
asserts the SQL actually sent.
rr:sqlQuery compiles to a deterministic alias (sqlQuery:<hash>) that stands
in wherever a table name is expected, so the scan operator, caches and
find_maps_for_table need no changes; a SQL source resolves the alias back
to the query and scans it as a derived table. Iceberg-backed sources refuse
such a mapping at registration instead of at first query.

The server route mirrors /iceberg/map with the SQL config surface (dialect,
protocol, catalog/schema, user, bearer or OAuth2, session properties) and
guards the endpoint against the link-local/metadata range. The CLI gains
fluree sql map|list|info|drop; list/info/drop share the mapped-source
implementations with fluree iceberg, whose family predicate now includes
SQL.
…ySQL and SQLite

A standalone workspace (excluded from the root one, like testsuite-sparql)
so sqlx and its three drivers never enter the fluree binary. It serves the
statement/page subset of the Trino client protocol: POST /v1/statement
describes the statement and starts streaming rows through a bounded channel;
each GET nextUri serves one page; DELETE cancels; an idle reaper drops
abandoned statements. Column types are reported in Trino's names, so the
Fluree side is unchanged whether it talks to Trino or to this.

Protocol tests run in-process over a real SQLite file. An env-gated test in
fluree-db-api (FLUREE_SQL_BRIDGE_URL) drives the real client against a live
bridge and skips loudly when unset. CI gains a job for the workspace.
'warehouse' read as if it meant something to the endpoint; it was only the
graph source name, and it collided with the Iceberg command's --warehouse
flag.
The dispatch check in scan_table / table_row_count re-ran the nameservice
lookup on every call, which on a storage-backed nameservice is two object
reads per scan — doubling a cost Iceberg scans already pay once. The
decision (and the opened SQL source) is now memoized in the per-query
session.
…ps SQL mappings

Snapshot pinning, incremental materialization and tracking parse the record
as IcebergGsConfig; a SQL record now gets a clear refusal naming the source
instead of a config-parse error. Hard drop resolves the mapping blob through
the family-aware lookup, so a dropped SQL source no longer leaks its CAS
mapping.

Also: a ledger-join test (FROM NAMED + GRAPH over the SQL source), and the
live test takes FLUREE_SQL_BRIDGE_CATALOG/SCHEMA so it runs against a real
Trino (verified on 483) and a Postgres-backed bridge (verified on 16).
The example used an object with a "graph" key, which the JSON-LD parser
reads as an ordinary node pattern and rejects ("Arrays in property values
not yet supported"). The working syntax — now pinned by the JSON-LD half of
ledger_and_sql_source_join_in_one_dataset — is the array form
["graph", <id>, <pattern>] with the source listed in "from-named".

Noted while verifying: a fromNamed dataset-local alias does not resolve to
a graph source ("Graph source '<alias>' not found"), and keying the alias
by the source id collides with the id's own registration; the full id via
"from-named" is the form that works. Both are pre-existing behaviors.
@bplatz bplatz added enhancement New feature or request area:iceberg Iceberg catalogs/credentials, R2RML, virtual datasets, materialize area:server HTTP surface, routes, error mapping, swagger, timeouts/admission, config graph area:cli fluree CLI UX, rdf toolkit, publish/export/insert flows labels Aug 31, 2026
@bplatz
bplatz requested review from aaj3f and zonotope August 31, 2026 00:16

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bplatz interested to learn more about the grounded-reality scenario that made you look into this, but that's purely an academic curiosity. Everything here makes sense and the work is good.

The one thing you may want to block on before merging: dialect::sql_string doesn't escape backslashes, which is a pathway for SQL injection into a MySQL-backed bridge (MySQL processes \ escapes in string literals under the default sql_mode, whereas Trino/Postgres/SQLite don't). A query-author-controlled FILTER value or subject-IRI key with a backslash breaks out of the string literal and bypasses the mapping's table boundary. It's a small, localized fix in the bridge, plus a MySQL-backed regression test.

Adherence checklist

  1. Patterns / abstractions ✔ — extends FlureeR2rmlProvider/R2RML operator, LogicalTable::SqlQuery alias unifies query-vs-table, Iceberg providers refuse SQL aliases; no parallel construct.
  2. Performance ✔ — engine flake loops untouched; per-scan (not per-row) rendering; streaming both sides; per-query client/dispatch/schema caches reduce overhead. One note: row_count issues a real backend COUNT(*).
  3. Testing ⚠️ — strong hermetic coverage for the client (wiremock) and the SQLite bridge; no MySQL/Postgres bridge tests, which is exactly where the injection hides.
  4. Conventions ⚠️ — clippy-clean on base lints, thiserror error idiom, self-describing commits; but fluree-db-sql is missing [lints] workspace = true. And: stacked ⇒ no CI ran — retarget to main before merge.

Comment thread fluree-db-sql/src/dialect.rs Outdated
}
}

fn sql_string(s: &str) -> String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — sql_string does not escape backslashes → SQL injection on MySQL-backed sources.

String literals are rendered here by wrapping in '…' and doubling embedded ' only; backslash passes through unescaped. That's safe for Trino, Postgres (default standard_conforming_strings=on), and SQLite — none process backslash escapes in string literals. MySQL, under its default sql_mode, treats \ as an escape character inside string literals. The bridge runs the engine-rendered SQL verbatim (fluree-sql-bridge/src/mysql.rs:155, sqlx::query(&sql).fetch(&mut *conn), no NO_BACKSLASH_ESCAPES — grep of the crate confirms none). So for a dialect=mysql source, a value like x\ renders as 'x\' — MySQL reads \' as an escaped quote, the string never closes, and the following SQL is parsed as code. The tainted value is query-author-controlled: render_literal pushes Literal::Str(s) (a FILTER on a String column) and Literal::TemplateKey(raw) (a subject-IRI value reversed through a subject template) straight through sql_string (:319, :361). And dialect=mysql isn't exotic — it's required for MySQL, because it selects backtick identifier quoting.

Scenario: a user with query access to a MySQL-backed SQL graph source issues … FILTER(?name = "a\' UNION SELECT secret, … FROM other_table -- "). The bridge hands MySQL SELECT … WHERE + "name" + = 'a\' UNION SELECT secret, … -- ', reading a table the R2RML mapping never exposed. (Stacked ; statements are likely blocked by sqlx's single-statement execution, but UNION/subquery/blind extraction is not.) The mapping-as-access-boundary is defeated, up to the bridge DB user's privileges.

Preferred fix (one place, canonical): make MySQL obey the standard-SQL string rule the renderer already assumes. In fluree-sql-bridge/src/mysql.rs, set NO_BACKSLASH_ESCAPES on every pooled connection:

let pool = MySqlPoolOptions::new()
    .max_connections(max_connections)
    .after_connect(|conn, _meta| Box::pin(async move {
        conn.execute("SET SESSION sql_mode = CONCAT(@@sql_mode, ',NO_BACKSLASH_ESCAPES')").await?;
        Ok(())
    }))
    .connect(url).await

Then sql_string's quote-doubling is correct for all four dialects. Alternative (engine-side defense-in-depth): make sql_string/render_literal dialect-aware and double \\\ for SqlDialect::Mysql — but that couples the renderer to MySQL's default mode and over-escapes if NO_BACKSLASH_ESCAPES is ever set, so the bridge-side fix is the clean one. Either way, add a MySQL-backed test that pushes a string filter containing \ and ' and asserts the injected value is treated as data — the current bridge tests are SQLite-only, the one backend this can't hit.

(The fix lives in fluree-sql-bridge/src/mysql.rs; commenting here on dialect.rs:287 because this is the render site that produces the unescaped literal.)

Comment thread fluree-db-sql/Cargo.toml
futures.workspace = true
async-stream = "0.3"

reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, fold-in — add [lints] workspace = true.

This new library crate omits the workspace-lints opt-in that siblings fluree-db-r2rml and fluree-db-tabular both carry, so it silently forgoes the 23 workspace-denied pedantic clippy lints (uninlined_format_args, semicolon_if_nothing_returned, …). It's clean on the base lints today, but nothing keeps it that way.

Append:

[lints]
workspace = true

and re-run cargo clippy -p fluree-db-sql --all-targets -- -D warnings.

};
}

if let (Some(url), Some(secret)) = (&req.oauth2_token_url, &req.oauth2_client_secret) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional (decision-needed, so fine to defer with the reason named) — secrets from the HTTP route are persisted as literal ConfigValues.

build_sql_config wraps auth_bearer/oauth2_client_secret in SqlConfigValue::Literal, so a secret submitted to POST /v1/fluree/sql/map is stored inline in the graph-source record config. The config layer supports env_var/secret_ref indirection (config.rs:76), but the HTTP path can only store literals — the secret lives at rest in the nameservice record. This matches the Iceberg REST catalog's model, so it's not a regression.

Worth a one-line note in docs/graph-sources/sql.md that the record carries the credential and should be protected accordingly — or a follow-up to accept a secret_ref through the route (a product call on whether the route should take refs).

}

/// Exact `COUNT(*)` with the given columns required non-null.
pub async fn count(&self, source: &LogicalSource, non_null_cols: &[String]) -> Result<u64> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awareness, not a change request — row_count runs a real backend COUNT(*). Cardinality is answered with SELECT COUNT(*) … WHERE c IS NOT NULL AND … against the endpoint, so on a large SQL table it's a full aggregate scan per cardinality request, where an Iceberg source uses manifest metadata. Fine as an estimate, and the backend may optimize COUNT(*) — just a real query cost the planner should know it's paying if cardinality is requested repeatedly.

Base automatically changed from feat/graphql-endpoint to main September 3, 2026 02:49
…rces

# Conflicts:
#	Cargo.lock
#	fluree-db-api/Cargo.toml
#	fluree-db-api/src/graph_source/cache.rs
`sql_string` rendered literals with standard-SQL quote doubling and passed
backslash through. That is the whole escaping rule on Trino, Postgres and
SQLite, but MySQL's default `sql_mode` reads a backslash as live inside a
literal, so a value ending in one escaped its own closing quote and the
remainder parsed as SQL. Both `Literal::Str` (a FILTER on a String column)
and `Literal::TemplateKey` (a subject IRI reversed through its template)
carry query-author text, so a `dialect: mysql` source could be made to read
tables its R2RML mapping never exposed.

Fixed at both ends, because they cover different deployments:

- The bridge sets `NO_BACKSLASH_ESCAPES` on every MySQL session it opens, so
  the standard rule the renderer assumes is the rule the server applies.
- The renderer declines the pushdown for a MySQL string carrying a backslash
  rather than escaping for a mode it cannot observe — `dialect` names the
  database *behind* an endpoint that need not be a bridge we configured.
  Declining is the same valve type mismatches already use: the in-engine
  FILTER is authoritative, so it costs I/O, never correctness.

Also here:

- MySQL- and Postgres-backed bridge tests, gated on service-container URLs
  with a guard test that fails if CI stops supplying them. SQLite is the one
  backend whose literals cannot misbehave, so it could not cover this.
- `[lints] workspace = true` on fluree-db-sql, which had been forgoing the
  workspace-denied pedantic set (3 findings, fixed). The bridge is a separate
  workspace and cannot inherit, so it mirrors the table.
- Docs: the escaping rule and its pushdown decline, that `COUNT` is a real
  backend aggregate scan, and that both registration paths store credentials
  as literals in the graph-source record.
Same class as the MySQL fix, one file over. `standard_conforming_strings` has
defaulted to `on` since 9.1, which is why Postgres was not exposed the way
MySQL was — but it remains a settable GUC, and a server, database or role with
it `off` would process backslash escapes inside ordinary literals. Setting it
per session costs nothing and drops the dependency on server configuration.

Asserts both session settings directly. That assertion is the whole fix for
MySQL (a default server does not set NO_BACKSLASH_ESCAPES, so removing the
after_connect fails it) and is weak for Postgres, where a default server passes
either way; noted as such at the test.
The expected probe vector was derived by reading the type map rather than by
running it, and got the boolean arm wrong: sqlx reports a MySQL BOOLEAN column
by its declared type, not as the TINYINT(1) it is stored as, so `trino_type`
takes the BOOLEAN arm and the value renders as a real boolean. Both backends
produce the same vector, which is the more useful thing to pin — a mapping
moves between them unchanged — so it is now one shared constant.
The branch added docs/cli/sql.md without linking it, which docs_coverage::readme_indexes_every_command pins.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli fluree CLI UX, rdf toolkit, publish/export/insert flows area:iceberg Iceberg catalogs/credentials, R2RML, virtual datasets, materialize area:server HTTP surface, routes, error mapping, swagger, timeouts/admission, config graph enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants