diff --git a/jest.config.js b/jest.config.js index 02ecafa..243a948 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,10 +11,10 @@ export default { coverageReporters: ['json-summary', 'text'], coverageThreshold: { global: { - lines: 65.49, - statements: 64.98, - branches: 56.08, - functions: 71.15, + lines: 67.89, + statements: 68.24, + branches: 64.36, + functions: 74.4, }, }, transform: { diff --git a/package-lock.json b/package-lock.json index 7e30510..abc44e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "glob": "^7.2.3", "inquirer": "^9.2.12", "js-yaml": "^4.1.0", + "lodash.clonedeep": "^4.5.0", "lodash.kebabcase": "^4.1.1", "millify": "^6.1.0", "n3": "^1.17.2", @@ -42,6 +43,7 @@ "@types/inquirer": "^9.0.7", "@types/jest": "^29.5.12", "@types/js-yaml": "^4.0.9", + "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.kebabcase": "^4.1.9", "@types/n3": "^1.16.4", "@types/node": "^20.12.12", @@ -7092,6 +7094,15 @@ "integrity": "sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==", "dev": true }, + "node_modules/@types/lodash.clonedeep": { + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.9.tgz", + "integrity": "sha512-19429mWC+FyaAhOLzsS8kZUsI+/GmBAQ0HFiCPsKGU+7pBXOQWhyrY6xNNDwUSX8SMZMJvuFVMF9O5dQOlQK9Q==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/lodash.kebabcase": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/lodash.kebabcase/-/lodash.kebabcase-4.1.9.tgz", @@ -13772,6 +13783,11 @@ "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", "dev": true }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", diff --git a/package.json b/package.json index 8508586..5fa7bd0 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "clean": "gts clean", "compile": "tsc", "fix": "gts fix", - "posttest": "npm run fix" + "posttest": "jest-coverage-thresholds-bumper --silent" }, "repository": { "type": "git", @@ -67,6 +67,7 @@ "@types/inquirer": "^9.0.7", "@types/jest": "^29.5.12", "@types/js-yaml": "^4.0.9", + "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.kebabcase": "^4.1.9", "@types/n3": "^1.16.4", "@types/node": "^20.12.12", @@ -92,6 +93,7 @@ "glob": "^7.2.3", "inquirer": "^9.2.12", "js-yaml": "^4.1.0", + "lodash.clonedeep": "^4.5.0", "lodash.kebabcase": "^4.1.1", "millify": "^6.1.0", "n3": "^1.17.2", diff --git a/src/generator.ts b/src/generator.ts index eeb0918..392b728 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -1,13 +1,14 @@ -import type {ConstructQuery} from 'sparqljs'; +import type {ConstructQuery, Pattern} from 'sparqljs'; import type Stage from './stage.js'; import getSPARQLQuery from './utils/getSPARQLQuery.js'; -import type {Quad, NamedNode} from '@rdfjs/types'; -import getSPARQLQueryString from './utils/getSPARQLQueryString.js'; +import type {NamedNode, Quad} from '@rdfjs/types'; import getEndpoint from './utils/getEndpoint.js'; import type {Endpoint, QueryEngine, QuerySource} from './types.js'; import getEngine from './utils/getEngine.js'; import getEngineSource from './utils/getEngineSource.js'; import EventEmitter from 'node:events'; +import {BaseQuery} from './sparql.js'; +import clonedeep from 'lodash.clonedeep'; const DEFAULT_BATCH_SIZE = 10; @@ -18,7 +19,7 @@ interface Events { } export default class Generator extends EventEmitter { - private readonly query: ConstructQuery; + private readonly query: Query; private readonly engine: QueryEngine; private iterationsProcessed = 0; private iterationsIncoming = 0; @@ -37,9 +38,11 @@ export default class Generator extends EventEmitter { ); super(); this.index = index; - this.query = getSPARQLQuery( - stage.configuration.generator[this.index].query, - 'construct' + this.query = Query.from( + getSPARQLQuery( + stage.configuration.generator[this.index].query, + 'construct' + ) ); this.endpoint = @@ -78,20 +81,10 @@ export default class Generator extends EventEmitter { `The Generator did not run successfully, it could not get the results from the endpoint ${this .source?.value}: ${(e as Error).message}` ); - const unionQuery = getSPARQLQuery( - getSPARQLQueryString(this.query), - 'construct' - ); - const patterns = unionQuery.where ?? []; - patterns.push({ - type: 'values', - values: batch.map($this => ({'?this': $this})), - }); - unionQuery.where = [{type: 'group', patterns}]; try { const stream = await this.engine.queryQuads( - getSPARQLQueryString(unionQuery), + this.query.withIris(batch).toString(), { sources: [(this.source ??= getEngineSource(this.endpoint))], } @@ -124,3 +117,61 @@ export default class Generator extends EventEmitter { await this.runBatch(this.$thisList); } } + +export class Query extends BaseQuery { + public static from(query: ConstructQuery) { + const self = new this(query); + self.validate(); + return self; + } + + private constructor(protected readonly query: ConstructQuery) { + super(query); + } + + public withIris(iris: NamedNode[]) { + const query = clonedeep(this.query); + const patterns: Pattern[] = [ + ...(query.where ?? []), + { + type: 'values', + values: iris.map($this => ({'?this': $this})), + }, + ]; + query.where = [{type: 'group', patterns}]; + + return new Query(query); + } + + protected validate() { + this.validatePreBinding(this.query.where ?? []); + } + + /** + * Because we use pre-binding, the query must follow the rules as specified by https://www.w3.org/TR/shacl/#pre-binding: + * - SPARQL queries must not contain a MINUS clause + * - SPARQL queries must not contain a federated query (SERVICE) + * - SPARQL queries must not contain a VALUES clause + * - SPARQL queries must not use the syntax form `AS ?var` for any potentially pre-bound variable + */ + private validatePreBinding(patterns: Pattern[]) { + for (const pattern of patterns) { + if (pattern.type === 'bind' && pattern.variable.value === 'this') { + throw new Error( + 'SPARQL CONSTRUCT generator query must not use the syntax form `AS ?this` because it is a pre-bound variable' + ); + } else if (['minus', 'service', 'values'].includes(pattern.type)) { + throw new Error( + `SPARQL CONSTRUCT generator query must not contain a ${pattern.type.toUpperCase()} clause` + ); + } else if ( + pattern.type === 'optional' || + pattern.type === 'union' || + pattern.type === 'group' || + pattern.type === 'graph' + ) { + this.validatePreBinding(pattern.patterns); + } + } + } +} diff --git a/src/iterator.ts b/src/iterator.ts index fcc11e7..1bf885e 100644 --- a/src/iterator.ts +++ b/src/iterator.ts @@ -4,12 +4,12 @@ import type Stage from './stage.js'; import type {NamedNode} from '@rdfjs/types'; import getSPARQLQuery from './utils/getSPARQLQuery.js'; import {type Bindings} from '@comunica/types'; -import getSPARQLQueryString from './utils/getSPARQLQueryString.js'; import getEndpoint from './utils/getEndpoint.js'; import type {Endpoint, QueryEngine, QuerySource} from './types.js'; import getEngine from './utils/getEngine.js'; import getEngineSource from './utils/getEngineSource.js'; import parse from 'parse-duration'; +import {BaseQuery} from './sparql.js'; const DEFAULT_LIMIT = 10; @@ -20,22 +20,20 @@ interface Events { } export default class Iterator extends EventEmitter { - private readonly query: SelectQuery; + private readonly query: Query; public readonly endpoint: Endpoint; private readonly engine: QueryEngine; private readonly delay: number = 0; private source?: QuerySource; - private $offset = 0; + private offset = 0; public totalResults = 0; constructor(stage: Stage) { super(); - this.query = getSPARQLQuery(stage.configuration.iterator.query, 'select'); - this.query.limit = - stage.configuration.iterator.batchSize ?? - this.query.limit ?? - DEFAULT_LIMIT; - this.validateQuery(); + this.query = Query.from( + getSPARQLQuery(stage.configuration.iterator.query, 'select'), + stage.configuration.iterator.batchSize + ); this.endpoint = getEndpoint(stage); this.engine = getEngine(this.endpoint); if (stage.configuration.iterator.delay !== undefined) { @@ -51,18 +49,17 @@ export default class Iterator extends EventEmitter { public async run(): Promise { setTimeout(async () => { let resultsPerPage = 0; - this.query.offset = this.$offset; - const queryString = getSPARQLQueryString(this.query); + this.query.offset = this.offset; const error = (e: unknown): Error => new Error( `The Iterator did not run successfully, it could not get the results from the endpoint ${ this.source - } (offset: ${this.$offset}, limit ${this.query.limit}): ${ + } (offset: ${this.offset}, limit ${this.query.limit}): ${ (e as Error).message }` ); try { - const stream = await this.engine.queryBindings(queryString, { + const stream = await this.engine.queryBindings(this.query.toString(), { sources: [(this.source ??= getEngineSource(this.endpoint))], }); @@ -81,7 +78,7 @@ export default class Iterator extends EventEmitter { }); stream.on('end', () => { this.totalResults += resultsPerPage; - this.$offset += this.query.limit!; + this.offset += this.query.limit; if (resultsPerPage < this.query.limit!) { this.emit('end', this.totalResults); } else { @@ -97,8 +94,29 @@ export default class Iterator extends EventEmitter { } }, this.delay); } +} + +export class Query extends BaseQuery { + public static from(query: SelectQuery, limit?: number) { + const self = new Query(query); + self.query.limit = limit ?? self.query.limit ?? DEFAULT_LIMIT; + self.validate(); + return self; + } + + private constructor(protected readonly query: SelectQuery) { + super(query); + } + + get limit(): number { + return this.query.limit!; + } + + set offset(offset: number) { + this.query.offset = offset; + } - private validateQuery() { + protected validate() { if ( !this.query.variables.find( v => @@ -106,7 +124,7 @@ export default class Iterator extends EventEmitter { ) ) { throw new Error( - 'The SPARQL query must select either a variable $this or a wildcard *' + 'The SPARQL iterator query must select either a variable $this or a wildcard *' ); } } diff --git a/src/sparql.ts b/src/sparql.ts new file mode 100644 index 0000000..4aff382 --- /dev/null +++ b/src/sparql.ts @@ -0,0 +1,12 @@ +import sparqljs, {type SparqlQuery} from 'sparqljs'; +const {Generator} = sparqljs; + +const generator = new Generator(); + +export abstract class BaseQuery { + protected constructor(protected readonly query: SparqlQuery) {} + + protected abstract validate(): void; + + public toString = () => generator.stringify(this.query); +} diff --git a/src/utils/getSPARQLQuery.ts b/src/utils/getSPARQLQuery.ts index 0e1a074..a1b5eec 100644 --- a/src/utils/getSPARQLQuery.ts +++ b/src/utils/getSPARQLQuery.ts @@ -1,11 +1,6 @@ import chalk from 'chalk'; import fs from 'fs'; -import { - type SelectQuery, - type ConstructQuery, - Parser, - type Pattern, -} from 'sparqljs'; +import {type SelectQuery, type ConstructQuery, Parser} from 'sparqljs'; type QueryTypes = 'select' | 'construct'; @@ -19,66 +14,24 @@ export default function getSPARQLQuery( queryStringOrFile: string, type: T ): QueryType { - let query = ''; - if (queryStringOrFile.startsWith('file://')) { - const file = queryStringOrFile.replace('file://', ''); - if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { - throw new Error(`File not found: ${chalk.italic(file)}`); - } - query = fs.readFileSync(file, 'utf-8'); - } else { - query = queryStringOrFile; - } + const query = queryStringOrFile.startsWith('file://') + ? readQueryFromFile(queryStringOrFile) + : queryStringOrFile; const parsed = new Parser().parse(query); if (parsed.type !== 'query') { throw new Error(`Unexpected querytype ${parsed.type}`); } if (parsed.queryType.toLowerCase() === type) { - const query = parsed as QueryType; - if (query.queryType === 'CONSTRUCT') { - checkSPARQLConstructQuery(query.where); - } - return query; - } else throw new Error(`Unexpected querytype ${parsed.queryType}`); + return parsed as QueryType; + } + + throw new Error(`Unexpected querytype ${parsed.queryType}`); } -/** - * because we use prebinding, our query must follow the rules as specified by - * https://www.w3.org/TR/shacl/#pre-binding: - * - SPARQL queries must not contain a MINUS clause - * - SPARQL queries must not contain a federated query (SERVICE) - * - SPARQL queries must not contain a VALUES clause - * - SPARQL queries must not use the syntax form `AS ?var` for any potentially pre-bound variable - */ -function checkSPARQLConstructQuery(patterns?: Pattern[]): void { - if (patterns === undefined) return; - for (const pattern of patterns) { - if (pattern.type === 'bind') { - if (pattern.variable.value === 'this') { - throw new Error( - 'SPARQL queries must not use the syntax form `AS ?this` because it is a pre-bound variable' - ); - } - } - if (pattern.type === 'minus') - throw new Error( - 'SPARQL construct queries must not contain a MINUS clause' - ); - if (pattern.type === 'service') - throw new Error( - 'SPARQL construct queries must not contain a SERVICE clause' - ); - if (pattern.type === 'values') - throw new Error( - 'SPARQL construct queries must not contain a VALUES clause' - ); - if ( - pattern.type === 'optional' || - pattern.type === 'union' || - pattern.type === 'group' || - pattern.type === 'graph' - ) { - checkSPARQLConstructQuery(pattern.patterns); - } +function readQueryFromFile(file: string): string { + const fileName = file.replace('file://', ''); + if (!fs.existsSync(fileName) || !fs.statSync(fileName).isFile()) { + throw new Error(`File not found: ${chalk.italic(fileName)}`); } + return fs.readFileSync(fileName, 'utf-8'); } diff --git a/src/utils/getSPARQLQueryString.ts b/src/utils/getSPARQLQueryString.ts deleted file mode 100644 index c80a1e3..0000000 --- a/src/utils/getSPARQLQueryString.ts +++ /dev/null @@ -1,10 +0,0 @@ -import sparqljs from 'sparqljs'; -import {type SelectQuery, type ConstructQuery} from 'sparqljs'; -const {Generator} = sparqljs; - -function getSPARQLQueryString(query: SelectQuery | ConstructQuery): string { - const generator = new Generator(); - return generator.stringify(query); -} - -export default getSPARQLQueryString; diff --git a/test/generator.test.ts b/test/generator.test.ts index 15f000b..ef75dce 100644 --- a/test/generator.test.ts +++ b/test/generator.test.ts @@ -1,4 +1,4 @@ -import Generator from '../src/generator.js'; +import Generator, {Query} from '../src/generator.js'; import {EventEmitter} from 'events'; import Stage from '../src/stage.js'; import Pipeline from '../src/pipeline.js'; @@ -11,9 +11,9 @@ import type {Configuration} from '../src/configuration.js'; import {fileURLToPath} from 'url'; import removeDirectory from '../src/utils/removeDir.js'; import {Quad} from '@rdfjs/types'; +import getSPARQLQuery from '../src/utils/getSPARQLQuery.js'; chai.use(chaiAsPromised); -const expect = chai.expect; describe('Generator Class', () => { const _filename = fileURLToPath(import.meta.url); @@ -62,11 +62,11 @@ describe('Generator Class', () => { const stageConfig = configuration.stages[0]; const stage = new Stage(pipeline, stageConfig); const generator = new Generator(stage, 0); - expect(generator).to.be.an.instanceOf(Generator); - expect(generator).to.be.an.instanceOf(EventEmitter); - expect(generator).to.have.property('query'); - expect(generator).to.have.property('engine'); - expect(generator).to.have.property('endpoint'); + chai.expect(generator).to.be.an.instanceOf(Generator); + chai.expect(generator).to.be.an.instanceOf(EventEmitter); + chai.expect(generator).to.have.property('query'); + chai.expect(generator).to.have.property('engine'); + chai.expect(generator).to.have.property('endpoint'); }); }); describe('run', () => { @@ -109,14 +109,18 @@ describe('Generator Class', () => { await pipelineParallelGenerators.run(); const file = fs.readFileSync(filePath, {encoding: 'utf-8'}); const fileLines = file.split('\n').sort(); - expect(fileLines.length).to.equal(741); - expect(fileLines[0]).to.equal(''); - expect(fileLines[1]).to.equal( - ' .' - ); - expect(fileLines[fileLines.length - 1]).to.equal( - ' "Instance 150 of the Iris Virginica"@en .' - ); + chai.expect(fileLines.length).to.equal(741); + chai.expect(fileLines[0]).to.equal(''); + chai + .expect(fileLines[1]) + .to.equal( + ' .' + ); + chai + .expect(fileLines[fileLines.length - 1]) + .to.equal( + ' "Instance 150 of the Iris Virginica"@en .' + ); }); it("Should work in batchSize for pipeline's generator", async () => { const filePath = 'pipelines/data/example-pipelineBatch.nt'; @@ -150,14 +154,18 @@ describe('Generator Class', () => { // read file after pipeline has finished const file = fs.readFileSync(filePath, {encoding: 'utf-8'}); const fileLines = file.split('\n').sort(); - expect(fileLines.length).to.equal(460); - expect(fileLines[0]).to.equal(''); - expect(fileLines[1]).to.equal( - ' .' - ); - expect(fileLines[fileLines.length - 1]).to.equal( - ' "Instance 150 of the Iris Virginica"@en .' - ); + chai.expect(fileLines.length).to.equal(460); + chai.expect(fileLines[0]).to.equal(''); + chai + .expect(fileLines[1]) + .to.equal( + ' .' + ); + chai + .expect(fileLines[fileLines.length - 1]) + .to.equal( + ' "Instance 150 of the Iris Virginica"@en .' + ); }) .catch(error => { throw error; @@ -224,19 +232,133 @@ describe('Generator Class', () => { } await runGeneratorWithPromise(); - expect(emittedEvents).to.have.lengthOf(4); - expect(emittedEvents[0].event).to.equal('data'); - expect(emittedEvents[0].quad?.subject.value).to.equal( - 'https://triplydb.com/triply/iris/id/floweringPlant/00106' - ); - expect(emittedEvents[0].quad?.predicate.value).to.equal( - 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' - ); - expect(emittedEvents[0].quad?.object.value).to.equal( - 'https://schema.org/Thing' + chai.expect(emittedEvents).to.have.lengthOf(4); + chai.expect(emittedEvents[0].event).to.equal('data'); + chai + .expect(emittedEvents[0].quad?.subject.value) + .to.equal('https://triplydb.com/triply/iris/id/floweringPlant/00106'); + chai + .expect(emittedEvents[0].quad?.predicate.value) + .to.equal('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'); + chai + .expect(emittedEvents[0].quad?.object.value) + .to.equal('https://schema.org/Thing'); + chai + .expect(emittedEvents[emittedEvents.length - 1].event) + .to.equal('end'); + chai + .expect(emittedEvents[emittedEvents.length - 1].numResults) + .to.equal(3); + }); + }); +}); + +describe('Query', () => { + const queryString = + 'CONSTRUCT { ?this ?value. }\nWHERE { }'; + const query = Query.from(getSPARQLQuery(queryString, 'construct')); + it('returns the query as string', () => { + expect(query.toString()).toEqual(queryString); + }); + + const invalidQueries = [ + { + clause: 'MINUS', + query: `PREFIX rdf: + PREFIX ex: + + CONSTRUCT { + ?city rdf:type ex:City. + } + WHERE { + ?city rdf:type ex:City. + MINUS { ?city ex:isCapitalOf ?country. } + }`, + }, + { + clause: 'MINUS', + query: `PREFIX ex: + CONSTRUCT { + ?city ex:hasPopulation ?population. + } + WHERE { + ?city ex:hasPopulation ?population. + + OPTIONAL { + MINUS { + ?city ex:hasPopulation ?otherPopulation. + FILTER (?population = ?otherPopulation) + } + } + }`, + }, + { + clause: 'SERVICE', + query: `PREFIX foaf: + PREFIX ex: + + CONSTRUCT { + ?person foaf:name ?name. + ?person ex:hasEmail ?email. + } + WHERE { + ?person foaf:name ?name. + SERVICE { + ?person ex:hasEmail ?email. + } + }`, + }, + { + clause: 'SERVICE', + query: `PREFIX ex: + CONSTRUCT { + ?place ex:hasPopulation ?population. + } + WHERE { + { + SERVICE { + ?place a ex:Country. + ?place ex:hasPopulation ?population. + } + } + }`, + }, + { + clause: 'VALUES', + query: `PREFIX ex: + CONSTRUCT { + ?city ex:hasPopulation ?population. + } + WHERE { + VALUES ?city { ex:City1 ex:City2 ex:City3 } + ?city ex:hasPopulation ?population. + }`, + }, + { + clause: 'VALUES', + query: `PREFIX ex: + CONSTRUCT { + ?city ex:hasPopulation ?population. + } + WHERE { + GRAPH ?graph { + VALUES (?city ?population) { + (ex:City1 10000) + (ex:City2 15000) + (ex:City3 20000) + } + + ?city ex:hasPopulation ?population. + } + }`, + }, + ]; + + describe.each(invalidQueries)('A query with clause ', ({clause, query}) => { + it(`${clause} is rejected`, () => { + expect(() => Query.from(getSPARQLQuery(query, 'construct'))).toThrow( + `SPARQL CONSTRUCT generator query must not contain a ${clause} clause` ); - expect(emittedEvents[emittedEvents.length - 1].event).to.equal('end'); - expect(emittedEvents[emittedEvents.length - 1].numResults).to.equal(3); }); }); }); diff --git a/test/iterator.test.ts b/test/iterator.test.ts index 0c195ec..b969b48 100644 --- a/test/iterator.test.ts +++ b/test/iterator.test.ts @@ -1,4 +1,4 @@ -import Iterator from '../src/iterator.js'; +import Iterator, {Query} from '../src/iterator.js'; import {EventEmitter} from 'events'; import Stage from '../src/stage.js'; import Pipeline from '../src/pipeline.js'; @@ -9,8 +9,8 @@ import {Configuration} from '../src/configuration.js'; import {fileURLToPath} from 'url'; import removeDirectory from '../src/utils/removeDir.js'; import {NamedNode} from '@rdfjs/types'; +import getSPARQLQuery from '../src/utils/getSPARQLQuery.js'; chai.use(chaiAsPromised); -const expect = chai.expect; describe('Iterator Class', () => { const _filename = fileURLToPath(import.meta.url); @@ -22,7 +22,7 @@ describe('Iterator Class', () => { }); describe('constructor', () => { - it('should set query, endpoint, engine, $offset, and totalResults properties correctly', () => { + it('should set query, endpoint, engine, offset, and totalResults properties correctly', () => { const configuration: Configuration = { name: 'Example Pipeline', description: @@ -59,13 +59,13 @@ describe('Iterator Class', () => { const stageConfig = configuration.stages[0]; const stage = new Stage(pipeline, stageConfig); const iterator = new Iterator(stage); - expect(iterator).to.be.an.instanceOf(Iterator); - expect(iterator).to.be.an.instanceOf(EventEmitter); - expect(iterator).to.have.property('query'); - expect(iterator).to.have.property('endpoint'); - expect(iterator).to.have.property('engine'); - expect(iterator).to.have.property('$offset', 0); - expect(iterator).to.have.property('totalResults', 0); + chai.expect(iterator).to.be.an.instanceOf(Iterator); + chai.expect(iterator).to.be.an.instanceOf(EventEmitter); + chai.expect(iterator).to.have.property('query'); + chai.expect(iterator).to.have.property('endpoint'); + chai.expect(iterator).to.have.property('engine'); + chai.expect(iterator).to.have.property('offset', 0); + chai.expect(iterator).to.have.property('totalResults', 0); }); }); describe.skip('run', () => { @@ -124,13 +124,49 @@ describe('Iterator Class', () => { } await runIteratorWithPromise(); - expect(emittedEvents).to.have.lengthOf(154); - expect(emittedEvents[0].event).to.equal('data'); - expect(emittedEvents[0].bindings?.termType).to.equal('NamedNode'); - expect(emittedEvents[0].bindings?.value).to.equal( - 'http://dbpedia.org/resource/Iris_virginica' - ); - expect(emittedEvents[emittedEvents.length - 1].event).to.equal('end'); + chai.expect(emittedEvents).to.have.lengthOf(154); + chai.expect(emittedEvents[0].event).to.equal('data'); + chai.expect(emittedEvents[0].bindings?.termType).to.equal('NamedNode'); + chai + .expect(emittedEvents[0].bindings?.value) + .to.equal('http://dbpedia.org/resource/Iris_virginica'); + chai + .expect(emittedEvents[emittedEvents.length - 1].event) + .to.equal('end'); }); }); }); + +describe('Query', () => { + const queryString = 'SELECT ?this WHERE { ?s ?p ?o. }\nLIMIT 30'; + const query = Query.from(getSPARQLQuery(queryString, 'select')); + + it('returns the query as a string', () => { + expect(query.toString()).toEqual(queryString); + }); + + it('reads the LIMIT from the query', () => { + expect(query.limit).toEqual(30); + }); + + it('sets the default LIMIT', () => { + const query = Query.from(getSPARQLQuery('SELECT ?this WHERE {}', 'select')); + expect(query.limit).toEqual(10); + }); + + it('overrides the LIMIT', () => { + const query = Query.from(getSPARQLQuery(queryString, 'select'), 500); + expect(query.limit).toEqual(500); + }); + + it('validates the query', () => { + expect(() => + Query.from( + getSPARQLQuery('SELECT ?nope WHERE { ?s ?p ?o. }', 'select'), + 500 + ) + ).toThrow( + 'The SPARQL iterator query must select either a variable $this or a wildcard *' + ); + }); +}); diff --git a/test/utils/utilities.test.ts b/test/utils/utilities.test.ts index da1ebe4..6adf296 100644 --- a/test/utils/utilities.test.ts +++ b/test/utils/utilities.test.ts @@ -24,7 +24,7 @@ import {existsSync, rename} from 'fs'; import * as chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import getSPARQLQuery from '../../src/utils/getSPARQLQuery.js'; -import getSPARQLQueryString from '../../src/utils/getSPARQLQueryString.js'; + chai.use(chaiAsPromised); const expect = chai.expect; @@ -41,6 +41,7 @@ function testDeepEqualTwoObjects( return false; } } + describe('Utilities', () => { it('should correctly get a version number', () => { expect(version()).match(/0.0.0-development/); @@ -657,9 +658,9 @@ describe('Utilities', () => { it('should throw if query is not a SPARQL query', () => { const sqlQuery = `SELECT first_name, last_name, birthdate - FROM employees - WHERE department = 'IT' - ORDER BY last_name, first_name; + FROM employees + WHERE department = 'IT' + ORDER BY last_name, first_name; `; let failed: boolean; try { @@ -676,249 +677,56 @@ describe('Utilities', () => { } expect(failed).to.equal(true); }); - describe('should throw if CONSTRUCT query contains minus, service, values', () => { - it('should throw for minus', () => { - const minusQuery = `PREFIX rdf: - PREFIX ex: - - CONSTRUCT { - ?city rdf:type ex:City. - } - WHERE { - ?city rdf:type ex:City. - MINUS { ?city ex:isCapitalOf ?country. } - } - - `; - expect(() => getSPARQLQuery(minusQuery, 'construct')).to.throw( - 'SPARQL construct queries must not contain a MINUS clause' - ); - }); - it('should throw for service', () => { - const serviceQuery = `PREFIX foaf: - PREFIX ex: - - CONSTRUCT { - ?person foaf:name ?name. - ?person ex:hasEmail ?email. - } - WHERE { - ?person foaf:name ?name. - SERVICE { - ?person ex:hasEmail ?email. - } - } - `; - expect(() => getSPARQLQuery(serviceQuery, 'construct')).to.throw( - 'SPARQL construct queries must not contain a SERVICE clause' - ); - }); - it('should throw for values', () => { - const valuesQuery = `PREFIX ex: - CONSTRUCT { - ?city ex:hasPopulation ?population. - } - WHERE { - VALUES ?city { ex:City1 ex:City2 ex:City3 } - ?city ex:hasPopulation ?population. - } - `; - expect(() => getSPARQLQuery(valuesQuery, 'construct')).to.throw( - 'SPARQL construct queries must not contain a VALUES clause' - ); - }); + it('should throw for incorrect syntax SPARQL query', () => { + const incorrectQuery = `SELECT ?subject ?predicate ?object + WHERE { + ?subject ?predicate ?object + FILTER (?object > 100) + `; + expect(() => getSPARQLQuery(incorrectQuery, 'select')).to.throw(); + }); + it('should throw for empty string', () => { + expect(() => getSPARQLQuery('', 'select')).to.throw( + 'Unexpected querytype undefined' + ); }); - describe('should throw if CONSTRUCT query contains optional, union, group, graph with minus, service, values', () => { - it('should throw for minus with optional', () => { - const minusOptionalQuery = `PREFIX ex: - - CONSTRUCT { - ?city ex:hasPopulation ?population. - } - WHERE { - ?city ex:hasPopulation ?population. - - OPTIONAL { - MINUS { - ?city ex:hasPopulation ?otherPopulation. - FILTER (?population = ?otherPopulation) - } - } - } - `; - expect(() => getSPARQLQuery(minusOptionalQuery, 'construct')).to.throw( - 'SPARQL construct queries must not contain a MINUS clause' - ); - }); - it('should throw for service with union', () => { - const serviceUnionQuery = `PREFIX ex: + it('should throw for UPDATE SPARQL query', () => { + const updateQuery = `PREFIX ex: - CONSTRUCT { - ?place ex:hasPopulation ?population. - } - WHERE { - { - ?place a ex:City. - ?place ex:hasPopulation ?population. + DELETE { + ex:City1 ex:hasPopulation ?newPopulation. } - UNION - { - SERVICE { - ?place a ex:Country. - ?place ex:hasPopulation ?population. - } - } - } - - `; - expect(() => getSPARQLQuery(serviceUnionQuery, 'construct')).to.throw( - 'SPARQL construct queries must not contain a SERVICE clause' - ); - }); - it('should throw for minus with group by', () => { - const minusGroupByQuery = `PREFIX ex: - - CONSTRUCT { - ?cityType ex:averagePopulation ?averagePopulation. - } - WHERE { - { - SELECT ?cityType (AVG(?population) as ?averagePopulation) - WHERE { - ?city ex:hasType ?cityType. - ?city ex:hasPopulation ?population. - } - GROUP BY ?cityType - } - MINUS - { - SELECT ?cityType - WHERE { - ?city ex:hasType ?cityType. - FILTER NOT EXISTS { - ?city ex:hasPopulation ?population. - } - } + WHERE { + ex:City1 ex:hasPopulation ?oldPopulation. + FILTER (?oldPopulation = "some_old_value") } - } - `; - expect(() => getSPARQLQuery(minusGroupByQuery, 'construct')).to.throw( - 'SPARQL construct queries must not contain a MINUS clause' - ); - }); - it('should throw for values', () => { - const valuesGraphQuery = `PREFIX ex: + `; + expect(() => getSPARQLQuery(updateQuery, 'select')).to.throw( + 'Unexpected querytype update' + ); + }); + it('should throw for ASK SPARQL query', () => { + const askQuery = `PREFIX ex: - CONSTRUCT { - ?city ex:hasPopulation ?population. - } - WHERE { - GRAPH ?graph { - VALUES (?city ?population) { - (ex:City1 10000) - (ex:City2 15000) - (ex:City3 20000) - } - - ?city ex:hasPopulation ?population. + ASK + WHERE { + ex:City1 ex:hasPopulation ?population. + FILTER (?population > 1000000) } - } - `; - expect(() => getSPARQLQuery(valuesGraphQuery, 'construct')).to.throw( - 'SPARQL construct queries must not contain a VALUES clause' - ); - }); + `; + expect(() => getSPARQLQuery(askQuery, 'select')).to.throw( + 'Unexpected querytype ASK' + ); }); - }); - describe('getSPARQLQueryString', () => { - it('should return query string for correct SELECT/CONSTRUCT SPARQL query', () => { - const selectQuery = `PREFIX ex: - - SELECT ?city ?population - WHERE { - ?city ex:hasPopulation ?population. - } - `; - const constructQuery = `PREFIX ex: - - CONSTRUCT { - ?city ex:hasPopulation ?population. - } - WHERE { - ?city ex:hasPopulation ?population. - } - `; + it('should throw for DESCRIBE SPARQL query', () => { + const describeQuery = `PREFIX ex: - const expectedSelectQuery = `PREFIX ex: -SELECT ?city ?population WHERE { ?city ex:hasPopulation ?population. }`; - const expectedConstructQuery = `PREFIX ex: -CONSTRUCT { ?city ex:hasPopulation ?population. } -WHERE { ?city ex:hasPopulation ?population. }`; - - const selectStr = getSPARQLQueryString( - getSPARQLQuery(selectQuery, 'select') - ); - const constructStr = getSPARQLQueryString( - getSPARQLQuery(constructQuery, 'construct') + DESCRIBE ex:City1 + `; + expect(() => getSPARQLQuery(describeQuery, 'select')).to.throw( + 'Unexpected querytype DESCRIBE' ); - - expect(selectStr).to.equal(expectedSelectQuery); - expect(constructStr).to.equal(expectedConstructQuery); - }); - describe('should throw error for incorrect SPARQL queries', () => { - it('should throw for incorrect syntax SPARQL query', () => { - const incorrectQuery = `SELECT ?subject ?predicate ?object - WHERE { - ?subject ?predicate ?object - FILTER (?object > 100) - `; - expect(() => - getSPARQLQueryString(getSPARQLQuery(incorrectQuery, 'select')) - ).to.throw(); - }); - it('should throw for empty string', () => { - expect(() => - getSPARQLQueryString(getSPARQLQuery('', 'select')) - ).to.throw('Unexpected querytype undefined'); - }); - it('should throw for UPDATE SPARQL query', () => { - const updateQuery = `PREFIX ex: - - DELETE { - ex:City1 ex:hasPopulation ?newPopulation. - } - WHERE { - ex:City1 ex:hasPopulation ?oldPopulation. - FILTER (?oldPopulation = "some_old_value") - } - `; - expect(() => - getSPARQLQueryString(getSPARQLQuery(updateQuery, 'select')) - ).to.throw('Unexpected querytype update'); - }); - it('should throw for ASK SPARQL query', () => { - const askQuery = `PREFIX ex: - - ASK - WHERE { - ex:City1 ex:hasPopulation ?population. - FILTER (?population > 1000000) - } - `; - expect(() => - getSPARQLQueryString(getSPARQLQuery(askQuery, 'select')) - ).to.throw('Unexpected querytype ASK'); - }); - it('should throw for DESCRIBE SPARQL query', () => { - const describeQuery = `PREFIX ex: - - DESCRIBE ex:City1 - `; - expect(() => - getSPARQLQueryString(getSPARQLQuery(describeQuery, 'select')) - ).to.throw('Unexpected querytype DESCRIBE'); - }); }); }); });