-
Notifications
You must be signed in to change notification settings - Fork 5
feat: triple store support for @cap-js/sqlite
#49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
bd87fc0
Add triple store support for SQLiteService to match HANA capabilities
BobdenOs b63a9a6
fix: harden SQLite knowledge graph loading
sjvans 72e2b16
Apply suggestion from @sjvans
sjvans 6598245
Merge branch 'AISQLiteService' into feat/knowledge-graph
sjvans File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]); | ||
| } | ||
|
|
||
| _ready() { | ||
| if (!oxigraph) | ||
| throw new Error(`Cannot find 'oxigraph'. Make sure to install it with 'npm i oxigraph'`); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 . }' | ||
| ) | ||
| } | ||
| }); | ||
| } | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 inSQLiteService.jstreat the return value as a synchronous object with a.RESPONSEproperty. In thesparql_tableSQLite user function the return ofthis._tripleStore.query(...)is used directly as.RESPONSE, which will be aPromiseobject, not the actual result string, causing the SQL function to silently return garbage.The
querymethod should be madeasyncand theloadpath should be awaited, or the two code paths should be separated so the SQLite scalar function callback can handle this correctly.Double-check suggestion before committing. Edit this comment for amendments.
Please provide feedback on the review comment by checking the appropriate box: