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 . }' + ) + } + }); + } +});