Skip to content
Open
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
42 changes: 42 additions & 0 deletions lib/knowledge-graph/SQLiteService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import SQLiteService from '@cap-js/sqlite'
import TripleStore from './triplestore.js'
import cds from '@sap/cds'

Check warning on line 3 in lib/knowledge-graph/SQLiteService.js

View workflow job for this annotation

GitHub Actions / lint

'cds' is defined but never used

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)

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: The SQLite user-defined function callback is synchronous, but this._tripleStore.query(...) is async (returns a Promise when handling LOAD) and even for regular SELECT queries the .RESPONSE value is accessed directly without awaiting. SQLite's better-sqlite3 (used by @cap-js/sqlite) does not support async user-defined functions; an async callback will silently return undefined to SQLite instead of the query result.

The sparql_table function must only be used for SELECT-type SPARQL queries (not LOAD), and the synchronous super.query() path must be kept synchronous. Confirm that oxigraph's Store.query() for SELECT is indeed synchronous and document this expectation explicitly.


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

return dbc
}
return factory
}
Comment on lines +11 to +20

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: Mutating the shared factory object on every get factory call causes race conditions and double-wrapping.

super.factory likely returns the same object reference each time. Every call to this getter overwrites factory._create with whatever factory.create currently is, then wraps it again. On the second call, factory._create becomes the already-wrapped function, so the real original is lost and each connection creation wraps another layer. Store the original once using a guard flag or wrap inside init() instead.

Suggested change
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
}
get factory() {
const factory = super.factory
if (!factory._create) {
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
}

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


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)

Check warning on line 25 in lib/knowledge-graph/SQLiteService.js

View workflow job for this annotation

GitHub Actions / lint

'_' is assigned a value but never used

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: The regex .exec(query) on line 25 can return null if the CALL SPARQL_EXECUTE(...) pattern does not match (e.g. malformed query), causing a destructuring TypeError at runtime.

The outer if only checks for CALL SPARQL_EXECUTE but the inner regex is more restrictive (requires two quoted string arguments). A malformed but matching prefix would crash the handler. The result should be checked before destructuring.

Suggested change
const [_, sparql, headers] = /SPARQL_EXECUTE\s*\(\s*'((?:[^']|'')*)'\s*,\s*'((?:[^']|'')*)'\s*,\s*\?\s*,\s*\?\s*\)/.exec(query)
const match = /SPARQL_EXECUTE\s*\(\s*'((?:[^']|'')*)'\s*,\s*'((?:[^']|'')*)'\s*,\s*\?\s*,\s*\?\s*\)/.exec(query)
if (!match) return super.onPlainSQL(req, next)
const [_, sparql, headers] = match

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

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) => {

Check warning on line 34 in lib/knowledge-graph/SQLiteService.js

View workflow job for this annotation

GitHub Actions / lint

'headers' is defined but never used
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])
Comment on lines +35 to +37

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.

Logic Error: The sparql_table SQL function builder computes last as the index of the first element in split whose length > 1, then takes cols as split.slice(0, last + 1). This logic assumes that all column-variable entries appear before any entry with more than one token — but that heuristic is fragile and breaks when the SPARQL projection contains AS aliases or when the variable list is formatted differently. Additionally, split.slice(1) discards the portion before the first ?, which silently drops any prefix text.

Consider parsing the SELECT projection variables with a proper regex (e.g. query.val.match(/\?\w+/g)) instead of relying on whitespace splitting around ?.

Suggested change
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])
const cols = (query.val.match(/\?\w+/g) ?? []).map(v => v.slice(1))

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

return `(SELECT ${cols.map(c => `value->>'$.${c}.value' as "${c}"`)} FROM json_each(sparql_table(${query})->'$.results.bindings'))`

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.

Security: The column names extracted from the SPARQL query string are interpolated directly into the SQL fragment without any sanitization. A SPARQL variable name like ?"; DROP TABLE Employees; -- would inject arbitrary SQL into the generated SELECT statement.

The extracted column names should be validated to contain only word characters (/^\w+$/) before being used in the SQL template.

Suggested change
return `(SELECT ${cols.map(c => `value->>'$.${c}.value' as "${c}"`)} FROM json_each(sparql_table(${query})->'$.results.bindings'))`
if (cols.some(c => !/^\w+$/.test(c))) throw new Error(`Invalid SPARQL variable name in: ${query.val}`)
return `(SELECT ${cols.map(c => `value->>'$.${c}.value' as "${c}"`)} FROM json_each(sparql_table(${query})->'$.results.bindings'))`

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

}
}
}
}
52 changes: 52 additions & 0 deletions lib/knowledge-graph/triplestore.js
Original file line number Diff line number Diff line change
@@ -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)) {

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: The regex /^\w*LOAD/i incorrectly matches queries that are not LOAD statements. \w* matches zero or more word characters, so strings like "SELECT LOAD ..." or "SELECTLOAD" would also match. SPARQL LOAD is always the first keyword; the pattern should anchor to optional whitespace only.

Consider using /^\s*LOAD\b/i to match only actual LOAD queries.

Suggested change
if (/^\w*LOAD/i.test(query)) {
if (/^\s*LOAD\b/i.test(query)) {

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

const [_, file, graph] = /LOAD <([^>]*)> INTO GRAPH <([^>]*)>/.exec(query)

Check warning on line 44 in lib/knowledge-graph/triplestore.js

View workflow job for this annotation

GitHub Actions / lint

'_' is assigned a value but never used

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: .exec(query) can return null for a LOAD query that doesn't match the expected LOAD <uri> INTO GRAPH <uri> pattern (e.g. LOAD <uri> without INTO GRAPH). Destructuring null throws a TypeError.

Should check the result before destructuring.

Suggested change
const [_, file, graph] = /LOAD <([^>]*)> INTO GRAPH <([^>]*)>/.exec(query)
const match = /LOAD <([^>]*)> INTO GRAPH <([^>]*)>/i.exec(query)
if (!match) throw new Error(`Unsupported LOAD syntax: ${query}`)
const [_, file, graph] = match

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

return this.load(file, graph)
}
const RESPONSE = super.query(query, { use_default_graph_as_union: true, results_format: accept })
return { RESPONSE }
}
Comment on lines +35 to +49

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'`) }
}
14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -49,6 +55,12 @@
"vcap": {
"label": "aicore"
}
},
"sql": {
"[development]": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would this be limited to the development profile?

"kind":"sqlite",
"impl": "@cap-js/ai/lib/knowledge-graph/SQLiteService.js"
}
Comment on lines +59 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"sql": {
"[development]": {
"kind":"sqlite",
"impl": "@cap-js/ai/lib/knowledge-graph/SQLiteService.js"
}
"sqlite": {
"impl": "@cap-js/ai/lib/knowledge-graph/SQLiteService.js"
}
  • after prioritizing @cap-js/sqlite
  • check that it merges

}
}
}
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.
39 changes: 39 additions & 0 deletions tests/bookshop/test/knowledge-graph.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Loading