From 42be10d516645b9198795377b5991cf3df45b0cf Mon Sep 17 00:00:00 2001 From: maartenvandenbrande Date: Tue, 23 Apr 2024 13:54:27 +0200 Subject: [PATCH] partial implementation of the FILTER operator --- .../config/query-operation/actors.json | 1 + .../query-operation/actors/query/filter.json | 16 ++ jest.config.js | 1 + .../.npmignore | 0 .../README.md | 30 +++ .../ActorQueryOperationIncrementalFilter.ts | 212 +++++++++++++++ .../lib/index.ts | 1 + .../package.json | 49 ++++ ...torQueryOperationIncrementalFilter-test.ts | 255 ++++++++++++++++++ 9 files changed, 565 insertions(+) create mode 100644 engines/config-query-sparql-incremental/config/query-operation/actors/query/filter.json create mode 100644 packages/actor-query-operation-incremental-filter/.npmignore create mode 100644 packages/actor-query-operation-incremental-filter/README.md create mode 100644 packages/actor-query-operation-incremental-filter/lib/ActorQueryOperationIncrementalFilter.ts create mode 100644 packages/actor-query-operation-incremental-filter/lib/index.ts create mode 100644 packages/actor-query-operation-incremental-filter/package.json create mode 100644 packages/actor-query-operation-incremental-filter/test/ActorQueryOperationIncrementalFilter-test.ts diff --git a/engines/config-query-sparql-incremental/config/query-operation/actors.json b/engines/config-query-sparql-incremental/config/query-operation/actors.json index 9f114fef..733ed625 100644 --- a/engines/config-query-sparql-incremental/config/query-operation/actors.json +++ b/engines/config-query-sparql-incremental/config/query-operation/actors.json @@ -7,6 +7,7 @@ "ccqs:config/query-operation/actors/query/join.json", "ccqs:config/query-operation/actors/query/project.json", "icqsi:config/query-operation/actors/query/quadpattern.json", + "icqsi:config/query-operation/actors/query/filter.json", "ccqs:config/query-operation/actors/query/union.json", "ccqs:config/query-operation/actors/query/minus.json", "ccqs:config/query-operation/actors/query/leftjoin.json", diff --git a/engines/config-query-sparql-incremental/config/query-operation/actors/query/filter.json b/engines/config-query-sparql-incremental/config/query-operation/actors/query/filter.json new file mode 100644 index 00000000..37648511 --- /dev/null +++ b/engines/config-query-sparql-incremental/config/query-operation/actors/query/filter.json @@ -0,0 +1,16 @@ +{ + "@context": [ + "https://linkedsoftwaredependencies.org/bundles/npm/@comunica/runner/^2.0.0/components/context.jsonld", + + "https://linkedsoftwaredependencies.org/bundles/npm/@incremunica/actor-query-operation-incremental-filter/^1.0.0/components/context.jsonld" + ], + "@id": "urn:comunica:default:Runner", + "@type": "Runner", + "actors": [ + { + "@id": "urn:comunica:default:query-operation/actors#filter", + "@type": "ActorQueryOperationIncrementalFilter", + "mediatorQueryOperation": { "@id": "urn:comunica:default:query-operation/mediators#main" } + } + ] +} diff --git a/jest.config.js b/jest.config.js index 6b7cad93..7ba0809e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,6 +16,7 @@ module.exports = { setupFilesAfterEnv: [ './setup-jest.js' ], collectCoverage: true, coveragePathIgnorePatterns: [ + '/actor-query-operation-incremental-filter/', 'util.ts', '/node_modules/', '/mocks/', diff --git a/packages/actor-query-operation-incremental-filter/.npmignore b/packages/actor-query-operation-incremental-filter/.npmignore new file mode 100644 index 00000000..e69de29b diff --git a/packages/actor-query-operation-incremental-filter/README.md b/packages/actor-query-operation-incremental-filter/README.md new file mode 100644 index 00000000..942b7594 --- /dev/null +++ b/packages/actor-query-operation-incremental-filter/README.md @@ -0,0 +1,30 @@ +# Incremunica Incremental Filter Query Operation Actor + +[![npm version](https://badge.fury.io/js/@incremunica%2Factor-query-operation-incremental-filter.svg)](https://badge.fury.io/js/@incremunica%2Factor-query-operation-incremental-filter) + +A [Query Operation](https://github.com/comunica/comunica/tree/master/packages/bus-query-operation) actor that handles [SPARQL filter](https://www.w3.org/TR/sparql11-query/#evaluation) operations. + +## Install + +```bash +$ yarn add @incremunica/actor-query-operation-incremental-filter +``` + +## Configure + +After installing, this package can be added to your engine's configuration as follows: +```text +{ + "@context": [ + ... + "https://linkedsoftwaredependencies.org/bundles/npm/@incremunica/actor-query-operation-incremental-filter/^1.0.0/components/context.jsonld" + ], + "actors": [ + ... + { + "@id": "urn:comunica:default:query-operation/actors#filter", + "@type": "ActorQueryOperationFilter", + "mediatorQueryOperation": { "@id": "urn:comunica:default:query-operation/mediators#main" } } + ] +} +``` diff --git a/packages/actor-query-operation-incremental-filter/lib/ActorQueryOperationIncrementalFilter.ts b/packages/actor-query-operation-incremental-filter/lib/ActorQueryOperationIncrementalFilter.ts new file mode 100644 index 00000000..59912b81 --- /dev/null +++ b/packages/actor-query-operation-incremental-filter/lib/ActorQueryOperationIncrementalFilter.ts @@ -0,0 +1,212 @@ +import type { IActorQueryOperationTypedMediatedArgs } from '@comunica/bus-query-operation'; +import { ActorQueryOperation, + ActorQueryOperationTypedMediated, + materializeOperation } from '@comunica/bus-query-operation'; +import type { IActorTest } from '@comunica/core'; +import { AsyncEvaluator, isExpressionError } from '@comunica/expression-evaluator'; +import type { IActionContext, IQueryOperationResult } from '@comunica/types'; +import { HashBindings } from '@incremunica/hash-bindings'; +import type { Bindings } from '@incremunica/incremental-bindings-factory'; +import { BindingsFactory, bindingsToString } from '@incremunica/incremental-bindings-factory'; +import type { BindingsStream } from '@incremunica/incremental-types'; +import { EmptyIterator, SingletonIterator, UnionIterator } from 'asynciterator'; +import type { Algebra } from 'sparqlalgebrajs'; + +/** + * A comunica Filter Sparqlee Query Operation Actor. + */ +export class ActorQueryOperationIncrementalFilter extends ActorQueryOperationTypedMediated { + public constructor(args: IActorQueryOperationTypedMediatedArgs) { + super(args, 'filter'); + } + + public async testOperation(operation: Algebra.Filter, context: IActionContext): Promise { + // eslint-disable-next-line no-console + console.warn(`SPARQL Filter isn't 100% supported and doesn't have 100% test coverage`); + if (operation.expression.expressionType === 'existence') { + return true; + } + if (operation.expression.expressionType === 'operator') { + const config = { ...ActorQueryOperation.getAsyncExpressionContext(context, this.mediatorQueryOperation) }; + const _ = new AsyncEvaluator(operation.expression, config); + return true; + } + throw new Error(`Filter expression (${operation.expression.expressionType}) not yet supported!`); + } + + public async runOperation(operation: Algebra.Filter, context: IActionContext): Promise { + let input = operation.input; + let currentOperation = operation.expression; + if (operation.expression.expressionType === 'operator' && operation.expression.operator === '&&') { + currentOperation = operation.expression.args[1]; + input = { + type: operation.type, + input: operation.input, + expression: { + type: operation.expression.args[0].type, + expressionType: operation.expression.args[0].expressionType, + operator: operation.expression.args[0].operator, + args: operation.expression.args[0].args, + }, + }; + } + + const outputRaw = await this.mediatorQueryOperation.mediate({ operation: input, context }); + const output = ActorQueryOperation.getSafeBindings(outputRaw); + ActorQueryOperation.validateQueryOutput(output, 'bindings'); + + const BF = new BindingsFactory(); + + if (currentOperation.expressionType !== 'existence') { + const config = { ...ActorQueryOperation.getAsyncExpressionContext(context, this.mediatorQueryOperation) }; + const evaluator = new AsyncEvaluator(currentOperation, config); + + const transform = async(item: Bindings, done: any, push: (bindings: Bindings) => void): Promise => { + try { + const result = await evaluator.evaluateAsEBV(item); + if (result) { + push(item); + } + } catch (error: unknown) { + // We ignore all Expression errors. + // Other errors (likely programming mistakes) are still propagated. + // + // > Specifically, FILTERs eliminate any solutions that, + // > when substituted into the expression, either result in + // > an effective boolean value of false or produce an error. + // > ... + // > These errors have no effect outside of FILTER evaluation. + // https://www.w3.org/TR/sparql11-query/#expressions + if (isExpressionError( error)) { + // In many cases, this is a user error, where the user should manually cast the variable to a string. + // In order to help users debug this, we should report these errors via the logger as warnings. + this.logWarn(context, 'Error occurred while filtering.', () => ({ + error, + bindings: bindingsToString(item), + })); + } else { + bindingsStream.emit('error', error); + } + } + done(); + }; + + const bindingsStream = output.bindingsStream.transform({ transform, autoStart: false }); + return { type: 'bindings', bindingsStream, metadata: output.metadata }; + } + const transformMap = new Map(); + + const hashBindings = new HashBindings(); + + const binder = async(bindings: Bindings, done: () => void, push: (i: BindingsStream) => void): Promise => { + const hash = hashBindings.hash(bindings); + let hashData = transformMap.get(hash); + if (bindings.diff) { + if (hashData === undefined) { + hashData = { + count: 1, + iterator: new EmptyIterator(), + currentState: false, + }; + transformMap.set(hash, hashData); + + const materializedOperation = materializeOperation(currentOperation.input, bindings); + const intermediateOutputRaw = await this.mediatorQueryOperation.mediate({ + operation: materializedOperation, + context, + }); + const intermediateOutput = ActorQueryOperation.getSafeBindings(intermediateOutputRaw); + + // A `destroy` could be called on the EmptyIterator before QueryOperation mediator has finished + if (hashData.count === 0) { + intermediateOutput.bindingsStream.destroy(); + done(); + return; + } + + let negBindings: Bindings; + let posBindings: Bindings; + + if (currentOperation.not) { + negBindings = BF.fromBindings(bindings); + hashData.currentState = true; + posBindings = BF.fromBindings(bindings); + posBindings.diff = false; + } else { + negBindings = BF.fromBindings(bindings); + negBindings.diff = false; + posBindings = BF.fromBindings(bindings); + } + let count = 0; + + const transform = ( + item: Bindings, + doneTransform: () => void, + pushTransform: (val: Bindings) => void, + ): void => { + if (item.diff) { + if (count === 0) { + if (hashData === undefined) { + throw new Error('hashData undefined, should not happen'); + } + hashData.currentState = !currentOperation.not; + for (let i = 0; i < hashData.count; i++) { + pushTransform(posBindings); + } + } + count++; + } else if (count > 1) { + count--; + } else { + count = 0; + if (hashData === undefined) { + throw new Error('hashData undefined, should not happen'); + } + hashData.currentState = currentOperation.not; + for (let i = 0; i < hashData.count; i++) { + pushTransform(negBindings); + } + } + doneTransform(); + }; + + const it = intermediateOutput.bindingsStream.transform({ + transform, + prepend: currentOperation.not ? [ bindings ] : undefined, + }); + + hashData.iterator = it; + push(it); + } else { + hashData.count++; + if (hashData.currentState) { + push(new SingletonIterator(bindings)); + } + } + } else { + if (hashData === undefined) { + done(); + return; + } + if (hashData.currentState) { + push(new SingletonIterator(bindings)); + } + if (hashData.count === 1) { + hashData.iterator.close(); + transformMap.delete(hash); + } + hashData.count--; + } + done(); + }; + + const bindingsStream = new UnionIterator(output.bindingsStream.transform({ + transform: binder, + }), { autoStart: false }); + return { type: 'bindings', bindingsStream, metadata: output.metadata }; + } +} diff --git a/packages/actor-query-operation-incremental-filter/lib/index.ts b/packages/actor-query-operation-incremental-filter/lib/index.ts new file mode 100644 index 00000000..44edaafe --- /dev/null +++ b/packages/actor-query-operation-incremental-filter/lib/index.ts @@ -0,0 +1 @@ +export * from './ActorQueryOperationIncrementalFilter'; diff --git a/packages/actor-query-operation-incremental-filter/package.json b/packages/actor-query-operation-incremental-filter/package.json new file mode 100644 index 00000000..96571976 --- /dev/null +++ b/packages/actor-query-operation-incremental-filter/package.json @@ -0,0 +1,49 @@ +{ + "name": "@incremunica/actor-query-operation-incremental-filter", + "version": "1.2.2", + "description": "An incremental-filter query-operation actor", + "lsd:module": true, + "main": "lib/index.js", + "typings": "lib/index", + "repository": { + "type": "git", + "url": "https://github.com/maartyman/incremunica.git", + "directory": "packages/actor-query-operation-incremental-filter" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "keywords": [ + "comunica", + "actor", + "query-operation", + "incremental-filter" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/maartyman/incremunica/issues" + }, + "homepage": "https://maartyman.github.io/incremunica/", + "files": [ + "components", + "lib/**/*.d.ts", + "lib/**/*.js", + "lib/**/*.js.map" + ], + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/expression-evaluator": "^2.10.0", + "@comunica/types": "^2.10.0", + "@incremunica/hash-bindings": "^1.2.2", + "@incremunica/incremental-bindings-factory": "^1.2.2", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + }, + "scripts": { + "build": "npm run build:ts && npm run build:components", + "build:ts": "node \"../../node_modules/typescript/bin/tsc\"", + "build:components": "componentsjs-generator" + } +} diff --git a/packages/actor-query-operation-incremental-filter/test/ActorQueryOperationIncrementalFilter-test.ts b/packages/actor-query-operation-incremental-filter/test/ActorQueryOperationIncrementalFilter-test.ts new file mode 100644 index 00000000..eec784a6 --- /dev/null +++ b/packages/actor-query-operation-incremental-filter/test/ActorQueryOperationIncrementalFilter-test.ts @@ -0,0 +1,255 @@ +import { BindingsFactory } from '@incremunica/incremental-bindings-factory'; +import { ActorQueryOperation } from '@comunica/bus-query-operation'; +import { KeysInitQuery } from '@comunica/context-entries'; +import { ActionContext, Bus } from '@comunica/core'; +import * as sparqlee from '@comunica/expression-evaluator'; +import { isExpressionError } from '@comunica/expression-evaluator'; +import type { IQueryOperationResultBindings, Bindings } from '@comunica/types'; +import { ArrayIterator } from 'asynciterator'; +import { DataFactory } from 'rdf-data-factory'; +import type { Algebra } from 'sparqlalgebrajs'; +import { Factory, translate } from 'sparqlalgebrajs'; +import { ActorQueryOperationIncrementalFilter } from '../lib'; +import '@comunica/jest'; +import '@incremunica/incremental-jest'; +import {EventEmitter} from "events"; + +const DF = new DataFactory(); +const BF = new BindingsFactory(); + +function template(expr: string) { + return ` +PREFIX xsd: +PREFIX fn: +PREFIX err: +PREFIX rdf: + +SELECT * WHERE { ?s ?p ?o FILTER (${expr})} +`; +} + +function parse(query: string): Algebra.Expression { + const sparqlQuery = translate(template(query)); + // Extract filter expression from complete query + return sparqlQuery.input.expression; +} + +async function partialArrayifyStream(stream: EventEmitter, num: number): Promise { + let array: V[] = []; + for (let i = 0; i < num; i++) { + await new Promise((resolve) => stream.once("data", (bindings: V) => { + array.push(bindings); + resolve(); + })); + } + return array; +} + +describe('ActorQueryOperationFilterSparqlee', () => { + let bus: any; + let mediatorQueryOperation: any; + const simpleSPOInput = new Factory().createBgp([ new Factory().createPattern( + DF.variable('s'), + DF.variable('p'), + DF.variable('o'), + ) ]); + const truthyExpression = parse('"nonemptystring"'); + const operationExpression = parse('?a > 0'); + const falsyExpression = parse('""'); + const erroringExpression = parse('?a + ?a'); + const unknownExpression = { + args: [], + expressionType: 'operator', + operator: 'DUMMY', + }; + + beforeEach(() => { + bus = new Bus({ name: 'bus' }); + mediatorQueryOperation = { + mediate: (arg: any) => Promise.resolve({ + bindingsStream: new ArrayIterator([ + BF.bindings([[ DF.variable('a'), DF.literal('1') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('2') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('3') ]]), + ], { autoStart: false }), + metadata: () => Promise.resolve({ cardinality: 3, canContainUndefs: false, variables: [ DF.variable('a') ]}), + operated: arg, + type: 'bindings', + }), + }; + }); + + describe('The ActorQueryOperationFilterSparqlee module', () => { + it('should be a function', () => { + expect(ActorQueryOperationIncrementalFilter).toBeInstanceOf(Function); + }); + + it('should be a ActorQueryOperationFilterSparqlee constructor', () => { + expect(new ( ActorQueryOperationIncrementalFilter)({ name: 'actor', bus, mediatorQueryOperation })) + .toBeInstanceOf(ActorQueryOperationIncrementalFilter); + expect(new ( ActorQueryOperationIncrementalFilter)({ name: 'actor', bus, mediatorQueryOperation })) + .toBeInstanceOf(ActorQueryOperation); + }); + + it('should not be able to create new ActorQueryOperationFilterSparqlee objects without \'new\'', () => { + expect(() => { ( ActorQueryOperationIncrementalFilter)(); }).toThrow(); + }); + }); + + describe('An ActorQueryOperationFilterSparqlee instance', () => { + let actor: ActorQueryOperationIncrementalFilter; + let factory: Factory; + + beforeEach(() => { + actor = new ActorQueryOperationIncrementalFilter({ name: 'actor', bus, mediatorQueryOperation }); + factory = new Factory(); + }); + + it('should test on filter operator', () => { + const op: any = { operation: { type: 'filter', expression: operationExpression }, context: new ActionContext() }; + return expect(actor.test(op)).resolves.toBeTruthy(); + }); + + it('should test on filter existence', () => { + const op: any = { operation: { type: 'filter', expression: {expressionType: 'existence'} }, context: new ActionContext() }; + return expect(actor.test(op)).resolves.toBeTruthy(); + }); + + it('should fail on unsupported operators 1', () => { + const op: any = { operation: { type: 'filter', expression: truthyExpression }, context: new ActionContext() }; + return expect(actor.test(op)).rejects.toBeTruthy(); + }); + + it('should fail on unsupported operators 2', () => { + const op: any = { operation: { type: 'filter', expression: unknownExpression }, context: new ActionContext() }; + return expect(actor.test(op)).rejects.toBeTruthy(); + }); + + it('should not test on non-filter', () => { + const op: any = { operation: { type: 'some-other-type' }}; + return expect(actor.test(op)).rejects.toBeTruthy(); + }); + + it('should return the full stream for a truthy filter', async() => { + const op: any = { operation: { type: 'filter', input: {}, expression: truthyExpression }, + context: new ActionContext() }; + const output: IQueryOperationResultBindings = await actor.run(op); + expect(await partialArrayifyStream(output.bindingsStream, 3)).toEqualBindingsArray([ + BF.bindings([[ DF.variable('a'), DF.literal('1') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('2') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('3') ]]), + ]); + expect(output.type).toEqual('bindings'); + expect(await output.metadata()) + .toMatchObject({ cardinality: 3, canContainUndefs: false, variables: [ DF.variable('a') ]}); + }); + + it('should return an empty stream for a falsy filter', async() => { + const op: any = { operation: { type: 'filter', input: {}, expression: falsyExpression }, + context: new ActionContext() }; + const output: IQueryOperationResultBindings = await actor.run(op); + await expect(output.bindingsStream).toEqualBindingsStream([]); + expect(await output.metadata()) + .toMatchObject({ cardinality: 3, canContainUndefs: false, variables: [ DF.variable('a') ]}); + expect(output.type).toEqual('bindings'); + }); + + it('should return an empty stream when the expressions error', async() => { + const op: any = { operation: { type: 'filter', input: {}, expression: erroringExpression }, + context: new ActionContext() }; + const output: IQueryOperationResultBindings = await actor.run(op); + await expect(output.bindingsStream).toEqualBindingsStream([]); + expect(await output.metadata()) + .toMatchObject({ cardinality: 3, canContainUndefs: false, variables: [ DF.variable('a') ]}); + expect(output.type).toEqual('bindings'); + }); + + it('Should log warning for an expressionError', async() => { + // The order is very important. This item requires isExpressionError to still have it's right definition. + const logWarnSpy = jest.spyOn( actor, 'logWarn'); + const op: any = { operation: { type: 'filter', input: {}, expression: erroringExpression }, + context: new ActionContext() }; + const output: IQueryOperationResultBindings = await actor.run(op); + output.bindingsStream.on('data', () => { + // This is here to force the stream to start. + }); + await new Promise(resolve => output.bindingsStream.on('end', resolve)); + expect(logWarnSpy).toHaveBeenCalledTimes(3); + logWarnSpy.mock.calls.forEach((call, index) => { + if (index === 0) { + const dataCB = <() => { error: any; bindings: Bindings }>call[2]; + const { error, bindings } = dataCB(); + expect(isExpressionError(error)).toBeTruthy(); + expect(bindings).toEqual(`{ + "a": "\\"1\\"" +}`); + } + }); + }); + + it('should emit an error for a hard erroring filter', async() => { + // eslint-disable-next-line no-import-assign + Object.defineProperty(sparqlee, 'isExpressionError', { writable: true }); + ( sparqlee).isExpressionError = jest.fn(() => false); + const op: any = { operation: { type: 'filter', input: {}, expression: erroringExpression }, + context: new ActionContext() }; + const output: IQueryOperationResultBindings = await actor.run(op); + output.bindingsStream.on('data', () => { + // This is here to force the stream to start. + }); + await new Promise(resolve => output.bindingsStream.on('error', () => resolve())); + }); + + it('should use and respect the baseIRI from the expression context', async() => { + const expression = parse('str(IRI(?a)) = concat("http://example.com/", ?a)'); + const context = new ActionContext({ + [KeysInitQuery.baseIRI.name]: 'http://example.com', + }); + const op: any = { operation: { type: 'filter', input: {}, expression }, context }; + const output: IQueryOperationResultBindings = await actor.run(op); + await expect(output.bindingsStream).toEqualBindingsStream([ + BF.bindings([[ DF.variable('a'), DF.literal('1') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('2') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('3') ]]), + ]); + expect(output.type).toEqual('bindings'); + expect(await output.metadata()) + .toMatchObject({ cardinality: 3, canContainUndefs: false, variables: [ DF.variable('a') ]}); + }); + + describe('should be able to handle EXIST filters', () => { + it('like a simple EXIST that is true', async() => { + // The actual bgp isn't used + const op: any = { operation: { type: 'filter', input: {}, expression: parse("EXISTS {?a a ?a}") }, + context: new ActionContext() }; + const output: IQueryOperationResultBindings = await actor.run(op); + expect(await partialArrayifyStream(output.bindingsStream, 3)).toBeIsomorphicBindingsArray([ + BF.bindings([[ DF.variable('a'), DF.literal('1') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('2') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('3') ]]), + ]); + expect(await output.metadata()) + .toMatchObject({ cardinality: 3, canContainUndefs: false, variables: [ DF.variable('a') ]}); + expect(output.type).toEqual('bindings'); + }); + + it('like a simple NOT EXIST that is true', async() => { + // The actual bgp isn't used + const op: any = { operation: { type: 'filter', input: {}, expression: parse("NOT EXISTS {?a a ?a}") }, + context: new ActionContext() }; + const output: IQueryOperationResultBindings = await actor.run(op); + expect(await partialArrayifyStream(output.bindingsStream, 6)).toBeIsomorphicBindingsArray([ + BF.bindings([[ DF.variable('a'), DF.literal('1') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('2') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('3') ]]), + BF.bindings([[ DF.variable('a'), DF.literal('1') ]], false), + BF.bindings([[ DF.variable('a'), DF.literal('2') ]], false), + BF.bindings([[ DF.variable('a'), DF.literal('3') ]], false), + ]); + expect(await output.metadata()) + .toMatchObject({ cardinality: 3, canContainUndefs: false, variables: [ DF.variable('a') ]}); + expect(output.type).toEqual('bindings'); + }); + }); + }); +});