From bd87fc07bf655a1f0a03e5c525f4c9039296e6c5 Mon Sep 17 00:00:00 2001 From: Bob den Os Date: Fri, 7 Aug 2026 23:39:08 +0200 Subject: [PATCH 1/3] Add triple store support for SQLiteService to match HANA capabilities --- CHANGELOG.md | 3 +- lib/knowledge-graph/triplestore.js | 80 +++++++++++++++++++++++++++++ lib/sqlite/AISQLiteService.js | 58 ++++++++++++++++++++- package.json | 9 +++- tests/bookshop/db/data/cap.ttl | 17 ++++++ tests/bookshop/db/data/cap.ttl.gz | Bin 0 -> 234 bytes tests/knowledge-graph.test.js | 65 +++++++++++++++++++++++ 7 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 lib/knowledge-graph/triplestore.js create mode 100644 tests/bookshop/db/data/cap.ttl create mode 100644 tests/bookshop/db/data/cap.ttl.gz create mode 100644 tests/knowledge-graph.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ffee0b7..7d828a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,7 @@ - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions - Synchronous execution suitable for SQLite user-defined functions - **Note**: Produces 384-dimensional vectors (vs. 768 in SAP HANA) for efficiency in local development scenarios - - +- 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..528cbfa --- /dev/null +++ b/lib/knowledge-graph/triplestore.js @@ -0,0 +1,80 @@ +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 { 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 = 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}`); + } + + const graphNode = graph == null ? oxigraph.defaultGraph() : oxigraph.namedNode(graph); + + const steps = [createReadStream(resolved)]; + let ext = path.extname(resolved).toLowerCase(); + if (ext.endsWith('.gz')) { + steps.push(createGunzip()); + ext = path.extname(resolved.slice(0, -3)).toLowerCase(); + } + const format = formats[ext]; + if (!format) throw new Error(`Unsupported RDF file format: ${ext || '(none)'}`); + 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..9c7850b 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,64 @@ 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'); + } + + const match = /^\s*SELECT\s+(?:DISTINCT\s+)?(.+?)\s+WHERE\b/is.exec(query.val); + if (!match || match[1].trim() === '*') { + 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) ?? []; + if (!variables.length || projection.replace(/\?[A-Za-z_][A-Za-z0-9_]*/g, '').trim()) { + throw new Error('sparql_table only supports simple SPARQL variable projections'); + } + + 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 8df0da8..1cc017d 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,20 @@ "devDependencies": { "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", - "onnxruntime-node": "1.20.1" + "onnxruntime-node": "1.20.1", + "oxigraph": "^0.5.9" }, "peerDependencies": { "@sap/cds": ">=9", - "onnxruntime-node": "1.20.1" + "onnxruntime-node": "1.20.1", + "oxigraph": "^0.5.9" }, "peerDependenciesMeta": { "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 0000000000000000000000000000000000000000..9595224c325550a296930f3844d6b1161960d88b GIT binary patch literal 234 zcmVGzC#7B@= z2~MEeM(J0+!wvnlV;KF9 k`f+lJH4Yh@SoKlQ1H;`{b-3@0+@Bcz36wdA { + 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/ + ); + }); + + 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 . }' + ) + } + }); + } +}); From b63a9a633b5ff4b02b565ef2393bffe5e3488967 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 25 Aug 2026 23:11:41 +0200 Subject: [PATCH 2/3] fix: harden SQLite knowledge graph loading --- lib/knowledge-graph/triplestore.js | 18 ++++++++--- lib/sqlite/AISQLiteService.js | 16 ++++++---- tests/knowledge-graph.test.js | 51 ++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/lib/knowledge-graph/triplestore.js b/lib/knowledge-graph/triplestore.js index 528cbfa..8a48f6d 100644 --- a/lib/knowledge-graph/triplestore.js +++ b/lib/knowledge-graph/triplestore.js @@ -8,6 +8,7 @@ try { 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'; @@ -26,22 +27,29 @@ export default class TripleStore extends (oxigraph?.Store || class Store {}) { async load(file, graph) { this._ready(); - const root = path.resolve(cds.root); + 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}`); } - const graphNode = graph == null ? oxigraph.defaultGraph() : oxigraph.namedNode(graph); - - const steps = [createReadStream(resolved)]; let ext = path.extname(resolved).toLowerCase(); if (ext.endsWith('.gz')) { - steps.push(createGunzip()); 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 }); } diff --git a/lib/sqlite/AISQLiteService.js b/lib/sqlite/AISQLiteService.js index 9c7850b..0a548e0 100644 --- a/lib/sqlite/AISQLiteService.js +++ b/lib/sqlite/AISQLiteService.js @@ -63,16 +63,20 @@ export default class AISQLiteService extends SQLiteService { throw new Error('sparql_table expects a literal SPARQL SELECT query'); } - const match = /^\s*SELECT\s+(?:DISTINCT\s+)?(.+?)\s+WHERE\b/is.exec(query.val); - if (!match || match[1].trim() === '*') { + // 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) ?? []; - if (!variables.length || projection.replace(/\?[A-Za-z_][A-Za-z0-9_]*/g, '').trim()) { - throw new Error('sparql_table only supports simple SPARQL variable projections'); - } + const variables = projection.match(/[?$][A-Za-z_][A-Za-z0-9_]*/g); const columns = variables.map((variable) => variable.slice(1)); const select = columns.map( diff --git a/tests/knowledge-graph.test.js b/tests/knowledge-graph.test.js index 52472cd..72eceac 100644 --- a/tests/knowledge-graph.test.js +++ b/tests/knowledge-graph.test.js @@ -1,5 +1,7 @@ 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'; @@ -48,6 +50,55 @@ describe('ai-sqlite knowledge graph', () => { ); }); + 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}>','', ?, ?)`); } From 72e2b16e283f9a2129065636c7d532656287820f Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:39:54 +0200 Subject: [PATCH 3/3] Apply suggestion from @sjvans --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d828a0..2604ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions - Synchronous execution suitable for SQLite user-defined functions - **Note**: Produces 384-dimensional vectors (vs. 768 in SAP HANA) for efficiency in local development scenarios -- Add local `SPARQL_EXECUTE` and `sparql_table` support to the `ai-sqlite` kind through the optional `oxigraph` peer dependency. +- 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