diff --git a/lib/knowledge-graph/SQLiteService.js b/lib/knowledge-graph/SQLiteService.js new file mode 100644 index 0000000..376e3e3 --- /dev/null +++ b/lib/knowledge-graph/SQLiteService.js @@ -0,0 +1,42 @@ +import SQLiteService from '@cap-js/sqlite' +import TripleStore from './triplestore.js' +import cds from '@sap/cds' + +export default class SQLiteServiceKG extends SQLiteService { + init() { + this._tripleStore ??= new TripleStore() + return super.init() + } + + get factory() { + const factory = super.factory + factory._create = factory.create + factory.create = async (tenant) => { + const dbc = await factory._create(tenant) + dbc.function('sparql_table', { deterministic: true }, (query) => this._tripleStore.query(query, 'accept: json').RESPONSE) + return dbc + } + return factory + } + + onPlainSQL(req, next) { + const { query } = req + if (/^\s*CALL SPARQL_EXECUTE/i.test(query)) { + const [_, sparql, headers] = /SPARQL_EXECUTE\s*\(\s*'((?:[^']|'')*)'\s*,\s*'((?:[^']|'')*)'\s*,\s*\?\s*,\s*\?\s*\)/.exec(query) + return this._tripleStore.query(sparql, headers) + } + return super.onPlainSQL(req, next) + } + + static CQN2SQL = class CQN2SQLKG extends SQLiteService.CQN2SQL { + static Functions = { + ...SQLiteService.CQN2SQL.Functions, + sparql_table: (query, headers) => { + const split = query.val.split('?').slice(1).map(m => m.trim().split(/\s+/)) + const last = split.findIndex(s => s.length > 1) + const cols = split.slice(0, last + 1).map(c => c[0]) + return `(SELECT ${cols.map(c => `value->>'$.${c}.value' as "${c}"`)} FROM json_each(sparql_table(${query})->'$.results.bindings'))` + } + } + } +} diff --git a/lib/knowledge-graph/triplestore.js b/lib/knowledge-graph/triplestore.js new file mode 100644 index 0000000..1de5128 --- /dev/null +++ b/lib/knowledge-graph/triplestore.js @@ -0,0 +1,52 @@ +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 + +export default class TripleStore extends (oxigraph?.Store || (class Store { })) { + + async load(file, graph) { + this._ready() + + const graphNode = graph == null + ? oxigraph.defaultGraph() + : oxigraph.namedNode(graph) + + const steps = [createReadStream(file)] + let ext = path.extname(file) + if (ext.endsWith('.gz')) { + steps.push(createGunzip()) + ext = path.extname(file.slice(0, -3)) + } + steps.push(text) + return super.load(await pipeline(...steps), { format: ext.slice(1), 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 + + // 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 } + } + + _ready() { if (!oxigraph) throw new Error(`Cannot find 'oxigraph'. Make sure to install it with 'npm i oxigraph'`) } +} diff --git a/package.json b/package.json index a16b543..4521290 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,13 @@ "@cap-js/cds-types": "^0.16.0" }, "peerDependencies": { - "@sap/cds": ">=9" + "@sap/cds": ">=9", + "oxigraph": "^0.5.9" + }, + "peerDependenciesMeta": { + "oxigraph": { + "optional": true + } }, "engines": { "node": ">=20.0.0" @@ -49,6 +55,12 @@ "vcap": { "label": "aicore" } + }, + "sql": { + "[development]": { + "kind":"sqlite", + "impl": "@cap-js/ai/lib/knowledge-graph/SQLiteService.js" + } } } } 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/bookshop/test/knowledge-graph.test.js b/tests/bookshop/test/knowledge-graph.test.js new file mode 100644 index 0000000..fb191b6 --- /dev/null +++ b/tests/bookshop/test/knowledge-graph.test.js @@ -0,0 +1,39 @@ +import TripleStore from '../../../lib/knowledge-graph/triplestore.js' +import cds from '@sap/cds' + +describe('Knowledge Graph', () => { + const { expect } = cds.test() + + beforeEach(() => { cds.db._tripleStore = new TripleStore() }) + + describe('sparql', () => { + const file = '/db/data/cap.ttl' + const graph = 'https://cap.cloud.sap/example' + + test('LOAD .ttl', async () => { + await cds.run(`CALL SPARQL_EXECUTE('LOAD <${cds.root}${file}> INTO GRAPH <${graph}>','', ?, ?)`) + const { results: { bindings: { 0: { count } } } } = JSON.parse(cds.db._tripleStore.query('SELECT (COUNT(?p) AS ?count) WHERE { ?s ?p ?o . }').RESPONSE) + expect(count).property('value').equal('13') + }) + + test('LOAD .ttl.gz', async () => { + await cds.run(`CALL SPARQL_EXECUTE('LOAD <${cds.root}${file}.gz> INTO GRAPH <${graph}>','', ?, ?)`) + const { results: { bindings: { 0: { count } } } } = JSON.parse(cds.db._tripleStore.query('SELECT (COUNT(?p) AS ?count) WHERE { ?s ?p ?o . }').RESPONSE) + expect(count).property('value').equal('13') + }) + + test('SELECT', async () => { + const query = { SELECT: { from: cds.ql.func('sparql_table', 'SELECT ?subject ?predicate ?object WHERE { ?subject ?predicate ?object .}') } } + + const empty = await cds.run(query) + expect(empty).property('length').eq(0) + + const res = await cds.run(`CALL SPARQL_EXECUTE('LOAD <${cds.root}${file}.gz> INTO GRAPH <${graph}>','', ?, ?)`) + const loaded = await cds.run(query) + expect(loaded).property('length').eq(13) + expect(loaded).property('0').property('subject') + expect(loaded).property('0').property('predicate') + expect(loaded).property('0').property('object') + }) + }) +})