diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7a163a..90f8aca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,8 +15,7 @@
- Synchronous execution suitable for SQLite user-defined functions
- Embeds one model input window; applications split long documents and store one vector per chunk
- **Note**: Produces 384-dimensional vectors (vs. 768 in SAP HANA) for efficiency in local development scenarios
-
-
+- Experimental!: Add local `SPARQL_EXECUTE` and `sparql_table` support to the `ai-sqlite` kind through the optional `oxigraph` peer dependency
## Version 1.1.0 - 2026-07-20
diff --git a/lib/knowledge-graph/triplestore.js b/lib/knowledge-graph/triplestore.js
new file mode 100644
index 0000000..8a48f6d
--- /dev/null
+++ b/lib/knowledge-graph/triplestore.js
@@ -0,0 +1,88 @@
+let oxigraph;
+try {
+ oxigraph = await import('oxigraph');
+} catch (err) {
+ if (err.code !== 'ERR_MODULE_NOT_FOUND') throw err;
+}
+
+import { pipeline } from 'node:stream/promises';
+import { text } from 'node:stream/consumers';
+import { createReadStream } from 'node:fs';
+import { realpath } from 'node:fs/promises';
+import { createGunzip } from 'node:zlib';
+
+import cds from '@sap/cds';
+const { path } = cds.utils;
+
+const formats = {
+ '.jsonld': 'application/ld+json',
+ '.nq': 'application/n-quads',
+ '.nt': 'application/n-triples',
+ '.rdf': 'application/rdf+xml',
+ '.trig': 'application/trig',
+ '.ttl': 'text/turtle'
+};
+
+export default class TripleStore extends (oxigraph?.Store || class Store {}) {
+ async load(file, graph) {
+ this._ready();
+
+ const root = await realpath(path.resolve(cds.root));
+ const resolved = path.resolve(root, file);
+ if (!resolved.startsWith(`${root}${path.sep}`)) {
+ throw new Error(`Cannot load RDF data from outside the project: ${file}`);
+ }
+
+ let ext = path.extname(resolved).toLowerCase();
+ if (ext.endsWith('.gz')) {
+ ext = path.extname(resolved.slice(0, -3)).toLowerCase();
+ }
+ const format = formats[ext];
+ if (!format) throw new Error(`Unsupported RDF file format: ${ext || '(none)'}`);
+
+ // Resolve the target before opening it: a project-local symlink must not make
+ // files outside of cds.root available through SPARQL LOAD.
+ const target = await realpath(resolved);
+ if (!target.startsWith(`${root}${path.sep}`)) {
+ throw new Error(`Cannot load RDF data from outside the project: ${file}`);
+ }
+
+ const graphNode = graph == null ? oxigraph.defaultGraph() : oxigraph.namedNode(graph);
+ const steps = [createReadStream(target)];
+ if (path.extname(resolved).toLowerCase() === '.gz') steps.push(createGunzip());
+ steps.push(text);
+ return super.load(await pipeline(...steps), { format, to_graph_name: graphNode });
+ }
+
+ query(query, headers) {
+ this._ready();
+
+ const accept = (
+ headers?.split('\r\n').find((header) => /accept:/i.test(header)) ??
+ 'accept:application/sparql-results+json'
+ )
+ .replace(/accept:/i, '')
+ .trim(); // strip HTTP header formatting
+
+ const RESPONSE = super.query(query, {
+ use_default_graph_as_union: true,
+ results_format: accept
+ });
+ return { RESPONSE };
+ }
+
+ async execute(query, headers) {
+ this._ready();
+
+ if (!/^\s*LOAD\b/i.test(query)) return this.query(query, headers);
+
+ const match = /^\s*LOAD\s+<([^>]*)>(?:\s+INTO\s+GRAPH\s+<([^>]*)>)?\s*$/i.exec(query);
+ if (!match) throw new Error(`Unsupported LOAD syntax: ${query}`);
+ return this.load(match[1], match[2]);
+ }
+
+ _ready() {
+ if (!oxigraph)
+ throw new Error(`Cannot find 'oxigraph'. Make sure to install it with 'npm i oxigraph'`);
+ }
+}
diff --git a/lib/sqlite/AISQLiteService.js b/lib/sqlite/AISQLiteService.js
index 120476c..0a548e0 100644
--- a/lib/sqlite/AISQLiteService.js
+++ b/lib/sqlite/AISQLiteService.js
@@ -1,7 +1,12 @@
import SQLiteService from '@cap-js/sqlite';
import { initializeEmbedding, vector_embedding } from '../vector_embedding/index.js';
+import TripleStore from '../knowledge-graph/triplestore.js';
+
+const $tripleStore = Symbol('tripleStore');
export default class AISQLiteService extends SQLiteService {
+ _tripleStores = new Map();
+
async init() {
await initializeEmbedding();
return super.init();
@@ -17,15 +22,68 @@ export default class AISQLiteService extends SQLiteService {
const deterministic = { deterministic: true };
dbc.function('VECTOR_EMBEDDING', { ...deterministic, varargs: true }, embedding);
dbc.function('VECTOR_EMBEDDING', deterministic, embedding);
+
+ const key = tenant ?? '';
+ const store = this._tripleStores.get(key) ?? new TripleStore();
+ this._tripleStores.set(key, store);
+ dbc[$tripleStore] = store;
+ dbc.function('sparql_table', (query) => store.query(query).RESPONSE);
return dbc;
};
return factory;
}
+ async disconnect(tenant) {
+ await super.disconnect(tenant);
+ if (tenant == null) this._tripleStores.clear();
+ else this._tripleStores.delete(tenant);
+ }
+
+ onPlainSQL(req, next) {
+ const { query } = req;
+ if (!/^\s*CALL\s+SPARQL_EXECUTE\b/i.test(query)) return super.onPlainSQL(req, next);
+
+ const match =
+ /^\s*CALL\s+SPARQL_EXECUTE\s*\(\s*'((?:[^']|'')*)'\s*,\s*'((?:[^']|'')*)'\s*,\s*\?\s*,\s*\?\s*\)\s*;?\s*$/i.exec(
+ query
+ );
+ if (!match) throw new Error(`Unsupported SPARQL_EXECUTE syntax: ${query}`);
+
+ const store = this.dbc?.[$tripleStore];
+ if (!store) throw new Error('SPARQL_EXECUTE requires an active database connection');
+ const unescape = (value) => value.replace(/''/g, "'");
+ return store.execute(unescape(match[1]), unescape(match[2]));
+ }
+
static CQN2SQL = class CQN2AISQLite extends SQLiteService.CQN2SQL {
- // add cqn2sql stuff here
static Functions = {
- ...SQLiteService.CQN2SQL.Functions
+ ...SQLiteService.CQN2SQL.Functions,
+ sparql_table(query) {
+ if (typeof query.val !== 'string') {
+ throw new Error('sparql_table expects a literal SPARQL SELECT query');
+ }
+
+ // Keep the SQL projection deliberately narrow, but accept the SPARQL
+ // prologue and the optional WHERE keyword (both are valid SPARQL).
+ const iri = '<(?:[^>\\\\]|\\\\.)*>';
+ const prologue = `(?:(?:BASE\\s+${iri}|PREFIX\\s+(?:[A-Za-z][A-Za-z0-9._-]*)?:\\s*${iri})\\s*)*`;
+ const match = new RegExp(
+ `^\\s*${prologue}SELECT\\s+(?:(?:DISTINCT|REDUCED)\\s+)?((?:[?$][A-Za-z_][A-Za-z0-9_]*\\s*)+)(?:WHERE\\s*)?\\{`,
+ 'is'
+ ).exec(query.val);
+ if (!match) {
+ throw new Error('sparql_table only supports explicitly projected SPARQL variables');
+ }
+
+ const projection = match[1];
+ const variables = projection.match(/[?$][A-Za-z_][A-Za-z0-9_]*/g);
+
+ const columns = variables.map((variable) => variable.slice(1));
+ const select = columns.map(
+ (column) => `value->>'$.${column}.value' as ${this.quote(column)}`
+ );
+ return `(SELECT ${select} FROM json_each(sparql_table(${query})->'$.results.bindings'))`;
+ }
};
};
}
diff --git a/package.json b/package.json
index 0bce33e..7fe7fa6 100644
--- a/package.json
+++ b/package.json
@@ -24,12 +24,14 @@
"@cap-js/cds-test": "^1",
"@cap-js/cds-types": "^0.16.0",
"@cap-js/sqlite": ">=2",
- "onnxruntime-node": "1.20.1"
+ "onnxruntime-node": "1.20.1",
+ "oxigraph": "^0.5.9"
},
"peerDependencies": {
"@cap-js/sqlite": ">=2",
"@sap/cds": ">=9",
- "onnxruntime-node": "1.20.1"
+ "onnxruntime-node": "1.20.1",
+ "oxigraph": "^0.5.9"
},
"peerDependenciesMeta": {
"@cap-js/sqlite": {
@@ -37,6 +39,9 @@
},
"onnxruntime-node": {
"optional": true
+ },
+ "oxigraph": {
+ "optional": true
}
},
"engines": {
diff --git a/tests/bookshop/db/data/cap.ttl b/tests/bookshop/db/data/cap.ttl
new file mode 100644
index 0000000..c58a8c0
--- /dev/null
+++ b/tests/bookshop/db/data/cap.ttl
@@ -0,0 +1,17 @@
+@prefix cap: .
+
+# CAP sample turtle file
+
+cap:Service cap:label "Service" .
+cap:DatabaseService a cap:Service .
+cap:DatabaseService cap:label "Database Service" .
+cap:SQLiteService a cap:DatabaseService .
+cap:SQLiteService cap:name "SQLite Service" .
+cap:SQLiteService cap:label "SQLite Service" .
+cap:HANAService cap:implementedBy cap:cap-js-sqlite .
+cap:HANAService a cap:DatabaseService .
+cap:HANAService cap:name "HANA Service" .
+cap:HANAService cap:label "HANA Service" .
+cap:HANAService cap:implementedBy cap:cap-js-hana .
+cap:cap-js-hana cap:label "@cap-js/hana" .
+cap:cap-js-sqlite cap:label "@cap-js/sqlite" .
diff --git a/tests/bookshop/db/data/cap.ttl.gz b/tests/bookshop/db/data/cap.ttl.gz
new file mode 100644
index 0000000..9595224
Binary files /dev/null and b/tests/bookshop/db/data/cap.ttl.gz differ
diff --git a/tests/knowledge-graph.test.js b/tests/knowledge-graph.test.js
new file mode 100644
index 0000000..72eceac
--- /dev/null
+++ b/tests/knowledge-graph.test.js
@@ -0,0 +1,116 @@
+import { after, before, beforeEach, describe, test } from 'node:test';
+import assert from 'node:assert';
+import { symlink, unlink, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import cds from '@sap/cds';
+
+describe('ai-sqlite knowledge graph', () => {
+ let db;
+
+ const data = fileURLToPath(new URL('./bookshop/db/data/cap.ttl', import.meta.url));
+ const graph = 'https://cap.cloud.sap/example';
+
+ before(async () => {
+ db = await cds.connect.to('knowledge-graph-db', {
+ kind: 'ai-sqlite',
+ credentials: { url: ':memory:' }
+ });
+ });
+
+ beforeEach(async () => {
+ await db.disconnect();
+ });
+
+ after(async () => {
+ await db?.disconnect();
+ });
+
+ test('loads Turtle data', async () => {
+ await load(data);
+ assert.strictEqual((await triples()).length, 13);
+ });
+
+ test('loads compressed Turtle data', async () => {
+ await load(`${data}.gz`);
+ assert.strictEqual((await triples()).length, 13);
+ });
+
+ test('rejects malformed SPARQL_EXECUTE calls', async () => {
+ await assert.rejects(
+ db.run(`CALL SPARQL_EXECUTE('SELECT * WHERE { ?s ?p ?o }')`),
+ /Unsupported SPARQL_EXECUTE syntax/
+ );
+ });
+
+ test('rejects RDF files outside the project', async () => {
+ await assert.rejects(
+ db.run(`CALL SPARQL_EXECUTE('LOAD ','', ?, ?)`),
+ /outside the project/
+ );
+ });
+
+ test('rejects project-local symlinks pointing outside the project', async () => {
+ const link = path.join(cds.root, 'tests/bookshop/db/data/outside.ttl');
+ await symlink('/etc/passwd', link);
+ try {
+ await assert.rejects(
+ db.run(`CALL SPARQL_EXECUTE('LOAD <${link}>','', ?, ?)`),
+ /outside the project/
+ );
+ } finally {
+ await unlink(link);
+ }
+ });
+
+ test('checks RDF format before trying to open the file', async () => {
+ await assert.rejects(
+ db.run(`CALL SPARQL_EXECUTE('LOAD <${data}.unsupported>','', ?, ?)`),
+ /Unsupported RDF file format: .unsupported/
+ );
+ });
+
+ test('supports SPARQL prologues and SELECT without WHERE', async () => {
+ await load(data);
+ const result = await db.run({
+ SELECT: {
+ from: cds.ql.func(
+ 'sparql_table',
+ `BASE \nPREFIX cap: \nSELECT ?subject ?predicate { ?subject ?predicate ?object . }`
+ )
+ }
+ });
+ assert.ok(result.length > 0);
+ assert.deepStrictEqual(Object.keys(result[0]), ['subject', 'predicate']);
+ });
+
+ test('keeps a graph unchanged when a valid RDF file is malformed', async () => {
+ await load(data);
+ const malformed = path.join(cds.root, 'tests/bookshop/db/data/malformed.ttl');
+ await writeFile(
+ malformed,
+ ' .\nnot turtle'
+ );
+ try {
+ await assert.rejects(load(malformed));
+ assert.strictEqual((await triples()).length, 13);
+ } finally {
+ await unlink(malformed);
+ }
+ });
+
+ async function load(file) {
+ return db.run(`CALL SPARQL_EXECUTE('LOAD <${file}> INTO GRAPH <${graph}>','', ?, ?)`);
+ }
+
+ async function triples() {
+ return db.run({
+ SELECT: {
+ from: cds.ql.func(
+ 'sparql_table',
+ 'SELECT ?subject ?predicate ?object WHERE { ?subject ?predicate ?object . }'
+ )
+ }
+ });
+ }
+});