Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
88 changes: 88 additions & 0 deletions lib/knowledge-graph/triplestore.js
Original file line number Diff line number Diff line change
@@ -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]);
}
Comment on lines +57 to +82

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.

Bug: query() can return a Promise (when the query is a LOAD) or a plain object { RESPONSE } (for regular queries), but callers in SQLiteService.js treat the return value as a synchronous object with a .RESPONSE property. In the sparql_table SQLite user function the return of this._tripleStore.query(...) is used directly as .RESPONSE, which will be a Promise object, not the actual result string, causing the SQL function to silently return garbage.

The query method should be made async and the load path should be awaited, or the two code paths should be separated so the SQLite scalar function callback can handle this correctly.

Suggested change
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
// oxigraph does not support LOAD queries
if (/^\w*LOAD/i.test(query)) {
const [_, file, graph] = /LOAD <([^>]*)> INTO GRAPH <([^>]*)>/.exec(query)
return this.load(file, graph)
}
const RESPONSE = super.query(query, { use_default_graph_as_union: true, results_format: accept })
return { RESPONSE }
}
async 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
// oxigraph does not support LOAD queries
if (/^\s*LOAD\b/i.test(query)) {
const match = /LOAD <([^>]*)> INTO GRAPH <([^>]*)>/i.exec(query)
if (!match) throw new Error(`Unsupported LOAD syntax: ${query}`)
const [_, file, graph] = match
return this.load(file, graph)
}
const RESPONSE = super.query(query, { use_default_graph_as_union: true, results_format: accept })
return { RESPONSE }
}

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful


_ready() {
if (!oxigraph)
throw new Error(`Cannot find 'oxigraph'. Make sure to install it with 'npm i oxigraph'`);
}
}
62 changes: 60 additions & 2 deletions lib/sqlite/AISQLiteService.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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'))`;
}
};
};
}
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,24 @@
"@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": {
"optional": true
},
"onnxruntime-node": {
"optional": true
},
"oxigraph": {
"optional": true
}
},
"engines": {
Expand Down
17 changes: 17 additions & 0 deletions tests/bookshop/db/data/cap.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@prefix cap: <https://cap.cloud.sap/> .

# 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" .
Binary file added tests/bookshop/db/data/cap.ttl.gz
Binary file not shown.
116 changes: 116 additions & 0 deletions tests/knowledge-graph.test.js
Original file line number Diff line number Diff line change
@@ -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 </etc/passwd>','', ?, ?)`),
/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 <https://cap.cloud.sap/>\nPREFIX cap: <https://cap.cloud.sap/>\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,
'<https://cap.cloud.sap/test> <https://cap.cloud.sap/test> <https://cap.cloud.sap/test> .\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 . }'
)
}
});
}
});
Loading