Skip to content

Repository files navigation

License: MIT GitHub Release GitHub Actions Workflow Status

SQL2RDF++

A command line utility to convert relational database tables to RDF using R2RML syntax, with support for YARRRML as a friendlier YAML front-end to R2RML.

Ultimately this is intended to prove out implementation of R2RML in C++ so that the code can be lifted into a DuckDB extension to support a COPY TO export from DuckDB.

The repo also includes a standalone SPARQL 1.1 Query grammar parser, and a SPARQL-to-SQL translator that runs an R2RML mapping in reverse to turn a SPARQL query into SQL that a relational engine can execute directly, without ever materializing the RDF graph. See SPARQL query parsing and SPARQL-to-SQL translation below.

Build targets

The project is structured as a reusable library plus a thin CLI application:

Target Type DuckDB dependency Description
sql2rdf_r2rml static library none Core R2RML implementation. Links only Serd (embedded). Suitable for use in other projects, including a DuckDB extension.
sql2rdf_yarrrml static library none YARRRML → R2RML translator. Publicly links sql2rdf_r2rml and privately links yaml-cpp (fetched via CMake FetchContent), so consumers of sql2rdf_r2rml alone stay free of the YAML dependency.
sql2rdf_sparql static library none Standalone SPARQL 1.1 Query grammar parser. No dependency on sql2rdf_r2rml, sql2rdf_yarrrml, DuckDB, yaml-cpp, or Serd — only the C++ standard library.
sql2rdf_sparql2sql static library none SPARQL-to-SQL translator. Publicly links both sql2rdf_r2rml and sql2rdf_sparql (translates a parsed SPARQL query against a parsed R2RML mapping into SQL); no DuckDB/yaml-cpp dependency of its own, so it links into the DuckDB-free test_runner.
SQL2RDF++ executable required CLI application. Compiles the DuckDB adapter (DuckDBConnection) and links the system or embedded DuckDB library. Gated by SQL2RDF_BUILD_CLI (default: ON when building standalone, OFF when consumed via FetchContent).
test_runner executable none Test suite using Catch2. All tests run against a mock SQL backend — no DuckDB required. Gated by SQL2RDF_BUILD_TESTS (default: ON when building standalone, OFF when consumed via FetchContent).
sparql2sql_duckdb_tests executable required Execution-correctness tests for the SPARQL-to-SQL translator: translates each fixture query and runs the resulting SQL against a real in-memory DuckDB database, asserting on actual result rows. Kept separate from test_runner specifically so that target stays DuckDB-free. Gated by SQL2RDF_BUILD_TESTS AND SQL2RDF_BUILD_CLI plus DuckDB availability; its cases also register with CTest.
format utility none Apply clang-format to all project C++ sources in-place. Only defined when building standalone.
format-check utility none Check formatting with clang-format --dry-run --Werror; exits non-zero if any file would change. Used in CI. Only defined when building standalone.
tidy utility none Run clang-tidy static analysis using .clang-tidy. Builds sql2rdf_r2rml first to ensure a fresh compilation database. Only defined when building standalone.
coverage utility none Run test_runner and render a test coverage report with gcovr. Requires configuring with -DSQL2RDF_ENABLE_COVERAGE=ON (adds --coverage instrumentation to the libraries and test_runner; GCC or Clang only) and gcovr on PATH. Report is written to build/coverage/index.html. Only defined when building standalone.

The Serd RDF syntax library is included as a git submodule under external/serd and compiled from source into the sql2rdf_r2rml library.

Dependency Verification

This project uses SHA256 checksum verification for all external dependencies to ensure build reproducibility and security:

  • FetchContent dependencies (Catch2, yaml-cpp, DuckDB): CMake's built-in URL_HASH parameter verifies the downloaded content matches expected SHA256 hashes. The build will fail if any dependency's content doesn't match.
  • Git submodules (Serd): A custom CMake verification function checks that the Serd submodule's commit hash matches the expected value. The build will fail if the submodule is at an unexpected commit.

Checksums are stored in cmake/Dependencies.cmake. The GitHub Actions workflow also verifies the Serd submodule commit hash before building.

Updating Checksums

When updating a dependency version, you must update the corresponding checksum:

  1. FetchContent dependencies: Download the new release tarball and compute its SHA256:

    curl -L https://github.com/user/repo/archive/refs/tags/vX.Y.Z.tar.gz | shasum -a 256

    Update the corresponding variable in cmake/Dependencies.cmake.

  2. Git submodules: After updating the submodule, get the new commit hash:

    git -C external/serd rev-parse HEAD

    Update SERD_EXPECTED_COMMIT in cmake/Dependencies.cmake and the GitHub Actions workflow.

Consuming via FetchContent

Downstream CMake projects can pull in the library targets directly:

include(FetchContent)
FetchContent_Declare(
  sql2rdf
  GIT_REPOSITORY https://github.com/nonodename/sql2rdf.git
  GIT_TAG        <tag-or-commit>
)
FetchContent_MakeAvailable(sql2rdf)

target_link_libraries(myapp PRIVATE sql2rdf::r2rml)   # or sql2rdf::yarrrml which will give you both that and r2rml
# target_link_libraries(myapp PRIVATE sql2rdf::sparql)     # standalone SPARQL query parser, unrelated to r2rml/yarrrml
# target_link_libraries(myapp PRIVATE sql2rdf::sparql2sql) # SPARQL-to-SQL translator, pulls in both r2rml and sparql

By default this gets you only sql2rdf_r2rml/sql2rdf_yarrrml/sql2rdf_sparql/sql2rdf_sparql2sql (exposed under the namespaced sql2rdf::r2rml/sql2rdf::yarrrml/sql2rdf::sparql/sql2rdf::sparql2sql ALIAS targets, plus their serd/yaml-cpp dependencies) — no test_runner, no SQL2RDF++ CLI, no Catch2 fetch, and no DuckDB probing or fetch. The format/format-check/tidy dev-utility targets are also skipped, avoiding a target-name collision with any identically-named targets in the consuming project.

If your own project already defines a serd target (e.g. from its own FetchContent/find_package of Serd), sql2rdf will reuse it instead of vendoring a second copy — just make sure that target exists before FetchContent_MakeAvailable(sql2rdf) runs.

If you do want sql2rdf's tests or CLI built as part of your own build, set the corresponding option to ON before FetchContent_MakeAvailable:

set(SQL2RDF_BUILD_TESTS ON CACHE BOOL "" FORCE)
set(SQL2RDF_BUILD_CLI ON CACHE BOOL "" FORCE)

Installed find_package() consumption is not supported — FetchContent/add_subdirectory-style source consumption is the only supported integration path.

Building

Requires a C++11-compliant compiler (GCC 5+, Clang 3.4+, MSVC 2015+).

To build the extension, first clone this repo. Then in the repo base locally run:

git submodule update --init --recursive

Then (assuming you already have DuckDB installed on your system)

cmake -B build
cmake --build build --target sql2rdf_r2rml      # library only
cmake --build build --target sql2rdf_sparql     # SPARQL query parser library only
cmake --build build --target sql2rdf_sparql2sql # SPARQL-to-SQL translator library only
cmake --build build --target SQL2RDF++          # CLI app (requires DuckDB)
cmake --build build --target test_runner        # tests (no DuckDB needed)
cmake --build build --target sql2rdf_benchmark  # SPARQL-to-SQL performance harness (requires DuckDB)
cmake --build build                             # all of the above

Run the tests:

cmake --build build --target tests   # build + run
# or
ctest --test-dir build

If you have Ninja installed you can generate a Ninja build instead of Make:

cmake -B build -G Ninja
cmake --build build

Standalone builds get the CLI and test suite by default. Pass -DSQL2RDF_BUILD_TESTS=OFF and/or -DSQL2RDF_BUILD_CLI=OFF to suppress either even in a standalone checkout (see Consuming via FetchContent for the downstream-consumer defaults).

Code quality

Requires clang-format and clang-tidy on PATH (e.g. brew install llvm or apt install clang-format clang-tidy):

cmake --build build --target format        # apply formatting in-place
cmake --build build --target format-check  # check only (non-zero exit if any file would change)
cmake --build build --target tidy          # run static analysis

Test coverage

Requires gcovr on PATH (e.g. pip install gcovr or brew install gcovr) and GCC or Clang. Configure with coverage instrumentation enabled, then build the coverage target:

cmake -B build -DSQL2RDF_ENABLE_COVERAGE=ON
cmake --build build --target coverage

This builds test_runner with --coverage, runs it, and writes an HTML report to build/coverage/index.html (plus a summary printed to the terminal). SQL2RDF_ENABLE_COVERAGE is off by default since instrumentation disables optimization.

DuckDB dependency

The SQL2RDF++ executable (and the gated sparql2sql_duckdb_tests target) requires DuckDB headers and a shared library; the core libraries and test_runner do not. For faster CI builds a system-installed DuckDB is assumed by default:

  • macOS: brew install duckdb
  • Debian/Ubuntu: install libduckdb-dev
  • Windows: download the C/C++ SDK from the DuckDB install page

To build DuckDB from source instead, pass -DUSE_EMBEDDED_DUCKDB=ON at configure time:

cmake -B build -DUSE_EMBEDDED_DUCKDB=ON

When embedding DuckDB, its own shell/CLI and unittest targets are disabled (BUILD_SHELL/BUILD_UNITTESTS forced OFF) so embedding DuckDB doesn't also pull in DuckDB's own test suite or shell binary.

See the GitHub Actions workflow for the exact install steps used in CI for each platform.

Testing

Tests are based on the example tables and mapping configurations from the W3C R2RML specification. Example mapping files are in tests/sourceR2RML/. YARRRML equivalents of the same examples, plus feature/error-handling fixtures, are in tests/sourceYARRRML/. SPARQL query fixtures (valid queries and invalid_*.rq error cases) are in tests/sourceSPARQL/. SPARQL-to-SQL translator query fixtures, translated against the R2RML fixtures above, are in tests/sourceSPARQL2SQL/.

test_runner (no DuckDB required) asserts only on the structural shape of translator-generated SQL, since it deliberately never links DuckDB. Execution-correctness testing — actually running the translated SQL and checking result rows — lives in the separate sparql2sql_duckdb_tests target (tests/duckdb/), which requires DuckDB and is built only when the CLI and tests are both enabled and DuckDB is available; its cases also register with CTest, so ctest --test-dir build runs both suites together when it's built.

All other tests run against a mock SQL backend (MockSQL.h) — no DuckDB installation is required to run them.

YARRRML support

YARRRML mapping files (.yml/.yaml/.yarrrml) are translated internally into R2RML statements and then built by the same R2RML engine used for .ttl mappings, so both formats produce identical output for equivalent mappings. (The statements are handed over directly rather than serialised to Turtle text and re-parsed, which avoids the escaping edge cases of a round-trip.) Example:

prefixes:
  ex: http://example.com/ns#

mappings:
  employee:
    sources:
      - table: EMP
    s: http://data.example.com/employee/$(EMPNO)
    po:
      - [a, ex:Employee]
      - [ex:name, $(ENAME)]
      - [ex:count, $(COUNT), xsd:integer]
      - [ex:nickname, $(NICKNAME), en~lang]
      - [ex:homepage, $(HOMEPAGE)~iri]

Supported subset:

  • prefixes, base, mappings/mapping.
  • sources/source (per-mapping, single entry or list — the first is used) with table/query; a top-level sources map of named sources referenced by name. access/type/credentials/queryFormulation/referenceFormulation are ignored.
  • subjects/subject/s (single entry or list — the first is used): $(COL) → column, mixed text → template, otherwise a constant IRI.
  • po/predicateobjects, in shortcut array form ([predicates, objects] or [predicates, objects, datatype-or-language]) or map form (predicates/predicate/p, objects/object/o). The a predicate with a constant class object is folded into rr:class on the subject map.
  • Object values: $(COL) → column (literal by default; ~iri forces an IRI), mixed text → template, a CURIE/absolute IRI → constant IRI, any other plain string → constant literal. Per-object {value:|v:, datatype:|language:} maps and [value, datatype-or-language] pairs are supported.
  • Mapping references (joins): {mapping: OTHER, condition(s): {function: equal, parameters: [[str1, $(CHILD)], [str2, $(PARENT)]]}}.
  • graphs/graph (single entry or list), both per-mapping and per-po entry (where g is also accepted). A plain IRI or CURIE becomes an rr:graph constant; $(COL) or mixed text becomes an rr:graphMap with rr:termType rr:IRI. A per-mapping graph attaches to the subject map, so it applies to every triple the mapping generates (including rr:class rdf:type triples); a per-po graph attaches to that predicate-object map only. One exception to the a-shortcut folding: a with a constant class normally folds into rr:class, but when that po entry carries its own graph it stays a real rdf:type predicate-object map instead, since rr:class triples would otherwise take the subject map's graphs. Named graphs are only visible in a quad output format — see -f nquads|trig under Usage.
  • Unknown per-mapping keys and unknown top-level keys (e.g. functions, targets) are not supported and are reported as non-fatal warnings (authors is ignored silently).

Non-fatal issues (unsupported keys, a mapping with no/multiple sources, an unresolved join-condition function, a non-string graph value, ...) are collected into R2RMLMapping::parseErrors in the default lenient mode, or raised as a std::runtime_error when parsing in strict mode (ignoreNonFatalErrors=false). Fatal problems (unreadable file, YAML syntax errors, a missing mappings key) always throw.

SPARQL query parsing

sql2rdf_sparql (namespace sparql::) is a standalone recursive-descent parser for the SPARQL 1.1 Query grammar. It has no dependency on the R2RML/YARRRML/DuckDB code and is not currently used by the mapping-to-RDF conversion pipeline — it exists to parse and inspect .rq query files.

Supported: all four query forms (SELECT/CONSTRUCT/DESCRIBE/ASK), prologue (BASE/PREFIX), dataset clauses, group graph patterns (OPTIONAL/MINUS/UNION/GRAPH/SERVICE/FILTER/BIND/VALUES), subqueries, solution modifiers (GROUP BY/HAVING/ORDER BY/LIMIT/OFFSET), the full property path algebra (alternative/sequence/inverse/negated property sets), triples including collections and blank-node property lists, the full expression grammar with aggregates (COUNT/SUM/MIN/MAX/AVG/SAMPLE/GROUP_CONCAT) and builtin functions, and EXISTS. SPARQL Update is out of scope.

sparql::Parser::parseFile/parseString produce an AST (include/sparql-parser/ast/); sparql::print (PrettyPrinter.h) renders it back to text. Parse errors are reported via sparql::ParseError.

The CLI exposes this parser directly via -Q <file.rq>, which parses the query and prints its AST to stdout, then exits without touching the mapping/database/output pipeline. See Usage below.

SPARQL-to-SQL translation

sql2rdf_sparql2sql (namespace sparql2sql::) is the one deliberate bridge between the R2RML/YARRRML mapping pipeline and the standalone SPARQL parser: it translates a parsed SPARQL query against a parsed R2RML mapping into a SQL query, by using the mapping's TriplesMap/PredicateObjectMap/TermMap structure in reverse. For each SPARQL triple pattern it enumerates every mapping source that could produce a matching triple and composes the per-pattern SQL relations via the SPARQL algebra (AND→inner join, OPTIONAL→left outer join, UNION→schema-extending union, MINUS→anti-join). This lets a SPARQL query run directly against the relational data — no materialized RDF graph in between.

The approach to conversion is based on the work of A. Chebotko et al., Semantics preserving SPARQL-to-SQL translation, Data Knowl. Eng. (2009), doi:10.1016/j.datak.2009.04.001

#include "sparql-parser/Parser.h"
#include "r2rml/R2RMLParser.h"
#include "sparql2sql/Translator.h"
#include "sparql2sql/DuckDbDialect.h"

sparql::Parser sparqlParser;
std::unique_ptr<sparql::ast::Query> query = sparqlParser.parseFile("query.rq");

r2rml::R2RMLParser mappingParser;
r2rml::R2RMLMapping mapping = mappingParser.parse("mapping.ttl");

sparql2sql::DuckDbDialect dialect;
std::string sql = sparql2sql::translateQuery(*query, mapping, dialect);
// sql is a single "SELECT ..." (or, for ASK, "SELECT EXISTS(...) AS ask") statement,
// ready to hand to r2rml::DuckDBConnection::execute() or any other SQLConnection.

Only SELECT/ASK query forms are supported. Named graphs work in reverse too: rr:graph/rr:graphMap make the graph a fourth term position, so GRAPH <iri> prunes candidates statically and GRAPH ?g binds ?g. FROM/FROM NAMED restrict the active dataset. Note that this uses strict RDF-dataset semantics — with no FROM clause the default graph holds only triples whose graph set is empty or names rr:defaultGraph, so named-graph triples are invisible outside a GRAPH block (mappings that never mention rr:graph are unaffected), and FROM replaces the default graph rather than adding to it. Property paths are translated by desugaring them into the same relational algebra: ^ (inverse), / (sequence), | (alternative), ? (zero-or-one) and negated property sets all work that way; the arbitrary-length operators * and + are also supported, but translate to a WITH RECURSIVE closure rather than a fixed algebra expression, directionally seeded from whichever endpoint is bound. Every SPARQL variable is represented as a plain SQL VARCHAR of the term's lexical form, but the translator additionally tracks the term's dimension — kind, datatype, language — from the mapping's rr:termType/rr:datatype/rr:language (and, with a TypeCatalog, R2RML §10.2's natural mapping of the column type). Where the mapping determines it, that dimension folds to a constant: this is what lets isIRI()/lang()/datatype() resolve at translation time, and lets comparisons, arithmetic, ORDER BY and MIN/MAX work numerically and temporally rather than lexicographically. Where the mapping cannot determine it — a predicate whose candidate term maps disagree, so the dimension genuinely differs from row to row — the dimension is carried into the generated SQL as a companion type-tag column and evaluated per row, so those builtins, RDF term equality, SPARQL's value comparison and §15.1 ordering all still work instead of the query being refused. Tag columns are emitted only for the variables that need them, so a query over a well-typed mapping generates exactly the SQL it did before. See doc/api.md's "Supported SPARQL subset / Known limitations" for the full, current list of what is and isn't translated.

The CLI exposes this via -T <file.rq> [--dialect <name>] (default and currently only dialect: duckdb), paired with the mapping-file positional argument; if the database-file positional is also given, the translated SQL is additionally executed and its result rows printed. See Usage below.

Usage

Usage: ./SQL2RDF++ [options] <mapping.ttl|mapping.yml> <database.db> <output.nt>

Arguments:
  mapping.ttl|mapping.yml   R2RML mapping file (Turtle) or YARRRML mapping
                            file (YAML); the format is chosen from the file
                            extension (.ttl -> R2RML, .yml/.yaml/.yarrrml ->
                            YARRRML) unless overridden with -y.
  database.db               DuckDB database file
  output.nt                 Output RDF file

Options:
  -f <format>          Output format (default: ntriples); ignored with -T.
                       One of: ntriples, turtle, nquads, trig. Only the
                       quad formats (nquads, trig) can represent named
                       graphs, so rr:graph/rr:graphMap is silently dropped
                       by ntriples and turtle.
  -y                   Force the mapping file to be parsed as YARRRML,
                       regardless of its extension
  -P                   Print the parsed mapping to stderr
  -Q <file.rq>         Parse a SPARQL query file and print its AST to
                       stdout, then exit (bypasses the mapping/database/
                       output pipeline entirely)
  -T <file.rq>         Translate a SPARQL query file into SQL against the
                       given R2RML/YARRRML mapping (uses an R2RML mapping in
                       reverse), then exit. Requires the mapping file
                       positional argument; mutually exclusive with -Q.
                       If the database.db positional argument is also given,
                       the translated SQL is additionally executed against it
                       (result rows to stdout, SQL echoed to stderr);
                       otherwise the SQL alone is printed to stdout.
  --dialect <name>     SQL dialect to translate for with -T (default: duckdb;
                       currently the only supported dialect)
  --pretty             Pretty-print the SQL generated by -T (newlines,
                       indentation, one column per line) for debugging;
                       has no effect on the SQL's meaning
  -h                   Show this help message

-Q and -T are independent, mutually-exclusive entry points that bypass the mapping/database/output pipeline used by the default R2RML/YARRRML→RDF conversion above:

./SQL2RDF++ -Q <query.rq>
# Parses a SPARQL query file and prints its AST to stdout.

./SQL2RDF++ -T <query.rq> <mapping.ttl|mapping.yml> [database.db] [--dialect duckdb]
# Translates the SPARQL query into SQL against the given mapping and prints
# it to stdout; if database.db is also given, executes it and prints the
# result rows instead.

About

C++ framework, command line for converting relational data to RDF and back

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages