feat: Patch containers by recreating Representation from metadata

Also included is a change to the Patching architecture.
Patching is now done by RepresentationPatchers that take a Representation as input.
This commit is contained in:
Joachim Van Herwegen
2021-09-13 15:32:15 +02:00
parent a1c3633a25
commit ef9703e284
18 changed files with 804 additions and 483 deletions

View File

@@ -0,0 +1,91 @@
import arrayifyStream from 'arrayify-stream';
import { DataFactory } from 'n3';
import type { Patch } from '../../../../src/ldp/http/Patch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import { ContainerPatcher } from '../../../../src/storage/patch/ContainerPatcher';
import type {
RepresentationPatcherInput,
RepresentationPatcher,
} from '../../../../src/storage/patch/RepresentationPatcher';
import { SOLID_META } from '../../../../src/util/Vocabularies';
const { namedNode, quad } = DataFactory;
describe('A ContainerPatcher', (): void => {
const identifier: ResourceIdentifier = { path: 'http://test.com/foo/' };
const patch: Patch = new BasicRepresentation([], 'type/patch');
let representation: Representation;
let args: RepresentationPatcherInput;
const patchResult = new BasicRepresentation([], 'internal/quads');
let patcher: jest.Mocked<RepresentationPatcher>;
let containerPatcher: ContainerPatcher;
beforeEach(async(): Promise<void> => {
representation = new BasicRepresentation([], 'internal/quads');
args = { patch, identifier, representation };
patcher = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(patchResult),
} as any;
containerPatcher = new ContainerPatcher(patcher);
});
it('can only handle container identifiers.', async(): Promise<void> => {
args.identifier = { path: 'http://test.com/foo' };
await expect(containerPatcher.canHandle(args)).rejects.toThrow('Only containers are supported.');
});
it('checks if the patcher can handle the input if there is no representation.', async(): Promise<void> => {
delete args.representation;
await expect(containerPatcher.canHandle(args)).resolves.toBeUndefined();
patcher.canHandle.mockRejectedValueOnce(new Error('unsupported patch'));
await expect(containerPatcher.canHandle(args)).rejects.toThrow('unsupported patch');
});
it('sends a mock representation with the correct type to the patcher to check support.', async(): Promise<void> => {
await expect(containerPatcher.canHandle(args)).resolves.toBeUndefined();
expect(patcher.canHandle).toHaveBeenCalledTimes(1);
expect(patcher.canHandle.mock.calls[0][0].representation?.metadata.contentType).toBe('internal/quads');
});
it('passes the arguments to the patcher if there is no representation.', async(): Promise<void> => {
delete args.representation;
await expect(containerPatcher.handle(args)).resolves.toBe(patchResult);
expect(patcher.handle).toHaveBeenCalledTimes(1);
expect(patcher.handle).toHaveBeenLastCalledWith(args);
});
it('creates a new representation with all generated metadata removed.', async(): Promise<void> => {
const triples = [
quad(namedNode('a'), namedNode('real'), namedNode('triple')),
quad(namedNode('a'), namedNode('generated'), namedNode('triple')),
];
const metadata = new RepresentationMetadata(identifier);
metadata.addQuad(triples[0].subject as any, triples[0].predicate as any, triples[0].object as any);
// Make one of the triples generated
metadata.addQuad(triples[0].subject as any,
triples[0].predicate as any,
triples[0].object as any,
SOLID_META.terms.ResponseMetadata);
args.representation = new BasicRepresentation(triples, metadata);
await expect(containerPatcher.handle(args)).resolves.toBe(patchResult);
expect(patcher.handle).toHaveBeenCalledTimes(1);
const callArgs = patcher.handle.mock.calls[0][0];
expect(callArgs.identifier).toBe(identifier);
expect(callArgs.patch).toBe(patch);
// Only content-type metadata
expect(callArgs.representation?.metadata.quads()).toHaveLength(1);
expect(callArgs.representation?.metadata.contentType).toBe('internal/quads');
// Generated data got removed
const data = await arrayifyStream(callArgs.representation!.data);
expect(data).toHaveLength(1);
expect(data[0].predicate.value).toBe('real');
});
});

View File

@@ -1,118 +0,0 @@
import type { Patch } from '../../../../src/ldp/http/Patch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import type {
RepresentationConverter,
RepresentationConverterArgs,
} from '../../../../src/storage/conversion/RepresentationConverter';
import { ConvertingPatchHandler } from '../../../../src/storage/patch/ConvertingPatchHandler';
import type { PatchHandlerArgs } from '../../../../src/storage/patch/PatchHandler';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
class SimpleConvertingPatchHandler extends ConvertingPatchHandler {
private readonly type: string;
public constructor(converter: RepresentationConverter, intermediateType: string, defaultType: string) {
super(converter, intermediateType, defaultType);
this.type = intermediateType;
}
public async patch(input: PatchHandlerArgs, representation?: Representation): Promise<Representation> {
return representation ?
new BasicRepresentation('patched', representation.metadata) :
new BasicRepresentation('patched', input.identifier, this.type);
}
}
describe('A ConvertingPatchHandler', (): void => {
const intermediateType = 'internal/quads';
const defaultType = 'text/turtle';
const identifier: ResourceIdentifier = { path: 'http://test.com/foo' };
const patch: Patch = new BasicRepresentation([], 'type/patch');
const representation: Representation = new BasicRepresentation([], 'application/trig');
let source: jest.Mocked<ResourceStore>;
let args: PatchHandlerArgs;
let converter: jest.Mocked<RepresentationConverter>;
let handler: jest.Mocked<SimpleConvertingPatchHandler>;
beforeEach(async(): Promise<void> => {
converter = {
handleSafe: jest.fn(async({ preferences }: RepresentationConverterArgs): Promise<any> =>
new BasicRepresentation('converted', Object.keys(preferences.type!)[0])),
} as any;
source = {
getRepresentation: jest.fn().mockResolvedValue(representation),
setRepresentation: jest.fn(async(id: ResourceIdentifier): Promise<ResourceIdentifier[]> => [ id ]),
} as any;
args = { patch, identifier, source };
handler = new SimpleConvertingPatchHandler(converter, intermediateType, defaultType) as any;
jest.spyOn(handler, 'patch');
});
it('converts the representation before calling the patch function.', async(): Promise<void> => {
await expect(handler.handle(args)).resolves.toEqual([ identifier ]);
// Convert input
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, { });
expect(converter.handleSafe).toHaveBeenCalledTimes(2);
expect(converter.handleSafe).toHaveBeenCalledWith({
representation: await source.getRepresentation.mock.results[0].value,
identifier,
preferences: { type: { [intermediateType]: 1 }},
});
// Patch
expect(handler.patch).toHaveBeenCalledTimes(1);
expect(handler.patch).toHaveBeenLastCalledWith(args, await converter.handleSafe.mock.results[0].value);
// Convert back
expect(converter.handleSafe).toHaveBeenLastCalledWith({
representation: await handler.patch.mock.results[0].value,
identifier,
preferences: { type: { 'application/trig': 1 }},
});
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
expect(source.setRepresentation)
.toHaveBeenLastCalledWith(identifier, await converter.handleSafe.mock.results[1].value);
expect(source.setRepresentation.mock.calls[0][1].metadata.contentType).toBe(representation.metadata.contentType);
});
it('expects the patch function to create a new representation if there is none.', async(): Promise<void> => {
source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError());
await expect(handler.handle(args)).resolves.toEqual([ identifier ]);
// Try to get input
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, { });
// Patch
expect(handler.patch).toHaveBeenCalledTimes(1);
expect(handler.patch).toHaveBeenLastCalledWith(args, undefined);
// Convert new Representation to default type
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
expect(converter.handleSafe).toHaveBeenLastCalledWith({
representation: await handler.patch.mock.results[0].value,
identifier,
preferences: { type: { [defaultType]: 1 }},
});
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
expect(source.setRepresentation)
.toHaveBeenLastCalledWith(identifier, await converter.handleSafe.mock.results[0].value);
expect(source.setRepresentation.mock.calls[0][1].metadata.contentType).toBe(defaultType);
});
it('rethrows the error if something goes wrong getting the representation.', async(): Promise<void> => {
const error = new Error('bad data');
source.getRepresentation.mockRejectedValueOnce(error);
await expect(handler.handle(args)).rejects.toThrow(error);
});
});

View File

@@ -0,0 +1,129 @@
import type { Patch } from '../../../../src/ldp/http/Patch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import type {
RepresentationConverter,
RepresentationConverterArgs,
} from '../../../../src/storage/conversion/RepresentationConverter';
import { ConvertingPatcher } from '../../../../src/storage/patch/ConvertingPatcher';
import type {
RepresentationPatcher,
RepresentationPatcherInput,
} from '../../../../src/storage/patch/RepresentationPatcher';
describe('A ConvertingPatcher', (): void => {
const intermediateType = 'internal/quads';
const defaultType = 'text/turtle';
const identifier: ResourceIdentifier = { path: 'http://test.com/foo' };
const patch: Patch = new BasicRepresentation([], 'type/patch');
const representation = new BasicRepresentation([], 'application/trig');
const patchResult = new BasicRepresentation([], 'internal/quads');
let args: RepresentationPatcherInput;
let converter: jest.Mocked<RepresentationConverter>;
let patcher: jest.Mocked<RepresentationPatcher>;
let convertingPatcher: ConvertingPatcher;
beforeEach(async(): Promise<void> => {
args = { patch, identifier, representation };
converter = {
canHandle: jest.fn(),
handle: jest.fn(async({ preferences }: RepresentationConverterArgs): Promise<any> =>
new BasicRepresentation('converted', Object.keys(preferences.type!)[0])),
} as any;
patcher = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(patchResult),
} as any;
convertingPatcher = new ConvertingPatcher(patcher, converter, intermediateType, defaultType);
});
it('rejects requests the converter cannot handle.', async(): Promise<void> => {
converter.canHandle.mockRejectedValueOnce(new Error('unsupported type'));
await expect(convertingPatcher.canHandle(args)).rejects.toThrow('unsupported type');
});
it('checks if the patcher can handle the input if there is no representation.', async(): Promise<void> => {
delete args.representation;
await expect(convertingPatcher.canHandle(args)).resolves.toBeUndefined();
patcher.canHandle.mockRejectedValueOnce(new Error('unsupported patch'));
await expect(convertingPatcher.canHandle(args)).rejects.toThrow('unsupported patch');
});
it('sends a mock representation with the correct type to the patcher to check support.', async(): Promise<void> => {
await expect(convertingPatcher.canHandle(args)).resolves.toBeUndefined();
expect(patcher.canHandle).toHaveBeenCalledTimes(1);
expect(patcher.canHandle.mock.calls[0][0].representation?.metadata.contentType).toBe(intermediateType);
});
it('converts the representation before calling the patcher.', async(): Promise<void> => {
const result = await convertingPatcher.handle(args);
expect(result.metadata.contentType).toBe('application/trig');
// Convert input
expect(converter.handle).toHaveBeenCalledTimes(2);
expect(converter.handle).toHaveBeenCalledWith({
representation,
identifier,
preferences: { type: { [intermediateType]: 1 }},
});
// Patch
expect(patcher.handle).toHaveBeenCalledTimes(1);
expect(patcher.handle)
.toHaveBeenLastCalledWith({ ...args, representation: await converter.handle.mock.results[0].value });
// Convert back
expect(converter.handle).toHaveBeenLastCalledWith({
representation: patchResult,
identifier,
preferences: { type: { 'application/trig': 1 }},
});
});
it('expects the patcher to create a new representation if there is none.', async(): Promise<void> => {
delete args.representation;
const result = await convertingPatcher.handle(args);
expect(result.metadata.contentType).toBe(defaultType);
// Patch
expect(patcher.handle).toHaveBeenCalledTimes(1);
expect(patcher.handle).toHaveBeenLastCalledWith(args);
// Convert new Representation to default type
expect(converter.handle).toHaveBeenCalledTimes(1);
expect(converter.handle).toHaveBeenLastCalledWith({
representation: patchResult,
identifier,
preferences: { type: { [defaultType]: 1 }},
});
});
it('does no conversion if there is no intermediate type.', async(): Promise<void> => {
convertingPatcher = new ConvertingPatcher(patcher, converter);
const result = await convertingPatcher.handle(args);
expect(result.metadata.contentType).toBe(patchResult.metadata.contentType);
// Patch
expect(converter.handle).toHaveBeenCalledTimes(0);
expect(patcher.handle).toHaveBeenCalledTimes(1);
expect(patcher.handle).toHaveBeenLastCalledWith(args);
});
it('does not convert to a default type if there is none.', async(): Promise<void> => {
delete args.representation;
convertingPatcher = new ConvertingPatcher(patcher, converter);
const result = await convertingPatcher.handle(args);
expect(result.metadata.contentType).toBe(patchResult.metadata.contentType);
// Patch
expect(converter.handle).toHaveBeenCalledTimes(0);
expect(patcher.handle).toHaveBeenCalledTimes(1);
expect(patcher.handle).toHaveBeenLastCalledWith(args);
});
});

View File

@@ -0,0 +1,63 @@
import type { Patch } from '../../../../src/ldp/http/Patch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { PatchHandlerInput } from '../../../../src/storage/patch/PatchHandler';
import type { RepresentationPatcher } from '../../../../src/storage/patch/RepresentationPatcher';
import { RepresentationPatchHandler } from '../../../../src/storage/patch/RepresentationPatchHandler';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
describe('A RepresentationPatchHandler', (): void => {
const identifier = { path: 'http://test.com/foo' };
const representation = new BasicRepresentation('', 'text/turtle');
const patch: Patch = new BasicRepresentation('', 'application/sparql-update');
const patchResult = new BasicRepresentation('', 'application/trig');
let input: PatchHandlerInput;
let source: jest.Mocked<ResourceStore>;
let patcher: jest.Mocked<RepresentationPatcher>;
let handler: RepresentationPatchHandler;
beforeEach(async(): Promise<void> => {
source = {
getRepresentation: jest.fn().mockResolvedValue(representation),
setRepresentation: jest.fn().mockResolvedValue([ identifier ]),
} as any;
input = { source, identifier, patch };
patcher = {
handleSafe: jest.fn().mockResolvedValue(patchResult),
} as any;
handler = new RepresentationPatchHandler(patcher);
});
it('calls the patcher with the representation from the store.', async(): Promise<void> => {
await expect(handler.handle(input)).resolves.toEqual([ identifier ]);
expect(patcher.handleSafe).toHaveBeenCalledTimes(1);
expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch, representation });
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
expect(source.setRepresentation).toHaveBeenLastCalledWith(identifier, patchResult);
});
it('calls the patcher with no representation if there is none.', async(): Promise<void> => {
source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError());
await expect(handler.handle(input)).resolves.toEqual([ identifier ]);
expect(patcher.handleSafe).toHaveBeenCalledTimes(1);
expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch });
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
expect(source.setRepresentation).toHaveBeenLastCalledWith(identifier, patchResult);
});
it('errors if the store throws a non-404 error.', async(): Promise<void> => {
const error = new BadRequestHttpError();
source.getRepresentation.mockRejectedValueOnce(error);
await expect(handler.handle(input)).rejects.toThrow(error);
});
});

View File

@@ -1,249 +0,0 @@
import 'jest-rdf';
import { namedNode, quad } from '@rdfjs/data-model';
import arrayifyStream from 'arrayify-stream';
import type { Quad } from 'rdf-js';
import { translate } from 'sparqlalgebrajs';
import type { SparqlUpdatePatch } from '../../../../src/ldp/http/SparqlUpdatePatch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import type { RepresentationConverterArgs,
RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter';
import { SparqlUpdatePatchHandler } from '../../../../src/storage/patch/SparqlUpdatePatchHandler';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { guardedStreamFrom } from '../../../../src/util/StreamUtil';
describe('A SparqlUpdatePatchHandler', (): void => {
let converter: jest.Mocked<RepresentationConverter>;
let handler: SparqlUpdatePatchHandler;
let source: jest.Mocked<ResourceStore>;
let startQuads: Quad[];
const defaultType = 'internal/not-quads';
const identifier = { path: 'http://test.com/foo' };
const fulfilledDataInsert = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 . }';
beforeEach(async(): Promise<void> => {
startQuads = [ quad(
namedNode('http://test.com/startS1'),
namedNode('http://test.com/startP1'),
namedNode('http://test.com/startO1'),
), quad(
namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2'),
) ];
converter = {
handleSafe: jest.fn(async({ representation, preferences }: RepresentationConverterArgs): Promise<any> =>
new BasicRepresentation(representation.data, Object.keys(preferences.type!)[0])),
} as any;
source = {
getRepresentation: jest.fn(async(): Promise<any> => new BasicRepresentation(startQuads, defaultType)),
setRepresentation: jest.fn(),
} as any;
handler = new SparqlUpdatePatchHandler(converter, defaultType);
});
async function basicChecks(quads: Quad[]): Promise<boolean> {
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, { });
expect(converter.handleSafe).toHaveBeenCalledTimes(2);
expect(converter.handleSafe).toHaveBeenCalledWith({
representation: await source.getRepresentation.mock.results[0].value,
identifier,
preferences: { type: { [INTERNAL_QUADS]: 1 }},
});
expect(converter.handleSafe).toHaveBeenLastCalledWith({
representation: expect.objectContaining({ binary: false, metadata: expect.any(RepresentationMetadata) }),
identifier,
preferences: { type: { [defaultType]: 1 }},
});
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
const setParams = source.setRepresentation.mock.calls[0];
expect(setParams[0]).toEqual(identifier);
expect(setParams[1]).toEqual(expect.objectContaining({
binary: true,
metadata: expect.any(RepresentationMetadata),
}));
expect(setParams[1].metadata.contentType).toEqual(defaultType);
await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads);
return true;
}
async function handle(query: string): Promise<void> {
const prefixedQuery = `prefix : <http://test.com/>\n${query}`;
await handler.handle({
source,
identifier,
patch: {
algebra: translate(prefixedQuery, { quads: true }),
data: guardedStreamFrom(prefixedQuery),
metadata: new RepresentationMetadata(),
binary: true,
} as SparqlUpdatePatch,
});
}
it('only accepts SPARQL updates.', async(): Promise<void> => {
const input = { source, identifier, patch: { algebra: {}} as SparqlUpdatePatch };
await expect(handler.canHandle(input)).resolves.toBeUndefined();
delete (input.patch as any).algebra;
await expect(handler.canHandle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('handles NOP operations by not doing anything.', async(): Promise<void> => {
await handle('');
expect(source.getRepresentation).toHaveBeenCalledTimes(0);
expect(converter.handleSafe).toHaveBeenCalledTimes(0);
expect(source.setRepresentation).toHaveBeenCalledTimes(0);
});
it('handles INSERT DATA updates.', async(): Promise<void> => {
await handle(fulfilledDataInsert);
expect(await basicChecks([ ...startQuads,
quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')),
quad(namedNode('http://test.com/s2'), namedNode('http://test.com/p2'), namedNode('http://test.com/o2')),
])).toBe(true);
});
it('handles DELETE DATA updates.', async(): Promise<void> => {
await handle('DELETE DATA { :startS1 :startP1 :startO1 }');
expect(await basicChecks(
[ quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')) ],
)).toBe(true);
});
it('handles DELETE WHERE updates with no variables.', async(): Promise<void> => {
const query = 'DELETE WHERE { :startS1 :startP1 :startO1 }';
await handle(query);
expect(await basicChecks(
[ quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')) ],
)).toBe(true);
});
it('handles DELETE WHERE updates with variables.', async(): Promise<void> => {
const query = 'DELETE WHERE { :startS1 :startP1 ?o }';
await handle(query);
expect(await basicChecks(
[ quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')) ],
)).toBe(true);
});
it('handles DELETE/INSERT updates with empty WHERE.', async(): Promise<void> => {
const query = 'DELETE { :startS1 :startP1 :startO1 } INSERT { :s1 :p1 :o1 . } WHERE {}';
await handle(query);
expect(await basicChecks([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s1'),
namedNode('http://test.com/p1'),
namedNode('http://test.com/o1')),
])).toBe(true);
});
it('handles composite INSERT/DELETE updates.', async(): Promise<void> => {
const query = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 };' +
'DELETE WHERE { :s1 :p1 :o1 . :startS1 :startP1 :startO1 }';
await handle(query);
expect(await basicChecks([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s2'),
namedNode('http://test.com/p2'),
namedNode('http://test.com/o2')),
])).toBe(true);
});
it('handles composite DELETE/INSERT updates.', async(): Promise<void> => {
const query = 'DELETE DATA { :s1 :p1 :o1 . :startS1 :startP1 :startO1 } ;' +
'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 }';
await handle(query);
expect(await basicChecks([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s1'),
namedNode('http://test.com/p1'),
namedNode('http://test.com/o1')),
quad(namedNode('http://test.com/s2'),
namedNode('http://test.com/p2'),
namedNode('http://test.com/o2')),
])).toBe(true);
});
it('rejects GRAPH inserts.', async(): Promise<void> => {
const query = 'INSERT DATA { GRAPH :graph { :s1 :p1 :o1 } }';
await expect(handle(query)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects GRAPH deletes.', async(): Promise<void> => {
const query = 'DELETE DATA { GRAPH :graph { :s1 :p1 :o1 } }';
await expect(handle(query)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects DELETE/INSERT updates with non-BGP WHERE.', async(): Promise<void> => {
const query = 'DELETE { :s1 :p1 :o1 } INSERT { :s1 :p1 :o1 } WHERE { ?s ?p ?o. FILTER (?o > 5) }';
await expect(handle(query)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects INSERT WHERE updates with a UNION.', async(): Promise<void> => {
const query = 'INSERT { :s1 :p1 :o1 . } WHERE { { :s1 :p1 :o1 } UNION { :s1 :p1 :o2 } }';
await expect(handle(query)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects non-DELETE/INSERT updates.', async(): Promise<void> => {
const query = 'MOVE DEFAULT TO GRAPH :newGraph';
await expect(handle(query)).rejects.toThrow(NotImplementedHttpError);
});
it('throws the error returned by the store if there is one.', async(): Promise<void> => {
source.getRepresentation.mockRejectedValueOnce(new Error('error'));
await expect(handle(fulfilledDataInsert)).rejects.toThrow('error');
});
it('creates a new resource if it does not exist yet.', async(): Promise<void> => {
startQuads = [];
source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError());
const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }';
await handle(query);
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
expect(converter.handleSafe).toHaveBeenLastCalledWith({
representation: expect.objectContaining({ binary: false, metadata: expect.any(RepresentationMetadata) }),
identifier,
preferences: { type: { [defaultType]: 1 }},
});
const quads =
[ quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')) ];
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
const setParams = source.setRepresentation.mock.calls[0];
expect(setParams[1].metadata.contentType).toEqual(defaultType);
await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads);
});
it('defaults to text/turtle if no default type was set.', async(): Promise<void> => {
handler = new SparqlUpdatePatchHandler(converter);
startQuads = [];
source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError());
const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }';
await handle(query);
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
const setParams = source.setRepresentation.mock.calls[0];
expect(setParams[1].metadata.contentType).toEqual('text/turtle');
});
});

View File

@@ -0,0 +1,228 @@
import 'jest-rdf';
import { namedNode, quad } from '@rdfjs/data-model';
import arrayifyStream from 'arrayify-stream';
import type { Quad } from 'rdf-js';
import { translate } from 'sparqlalgebrajs';
import type { SparqlUpdatePatch } from '../../../../src/ldp/http/SparqlUpdatePatch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import type { RepresentationPatcherInput } from '../../../../src/storage/patch/RepresentationPatcher';
import { SparqlUpdatePatcher } from '../../../../src/storage/patch/SparqlUpdatePatcher';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { guardedStreamFrom } from '../../../../src/util/StreamUtil';
function getPatch(query: string): SparqlUpdatePatch {
const prefixedQuery = `prefix : <http://test.com/>\n${query}`;
return {
algebra: translate(prefixedQuery, { quads: true }),
data: guardedStreamFrom(prefixedQuery),
metadata: new RepresentationMetadata(),
binary: true,
};
}
describe('A SparqlUpdatePatcher', (): void => {
let patcher: SparqlUpdatePatcher;
let startQuads: Quad[];
let representation: Representation;
const identifier = { path: 'http://test.com/foo' };
const fulfilledDataInsert = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 . }';
beforeEach(async(): Promise<void> => {
startQuads = [ quad(
namedNode('http://test.com/startS1'),
namedNode('http://test.com/startP1'),
namedNode('http://test.com/startO1'),
), quad(
namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2'),
) ];
representation = new BasicRepresentation(startQuads, 'internal/quads');
patcher = new SparqlUpdatePatcher();
});
it('only accepts SPARQL updates.', async(): Promise<void> => {
const input = { identifier, patch: { algebra: {}} as SparqlUpdatePatch };
await expect(patcher.canHandle(input)).resolves.toBeUndefined();
delete (input.patch as any).algebra;
await expect(patcher.canHandle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('handles NOP operations by not doing anything.', async(): Promise<void> => {
let patch = getPatch('');
let input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).resolves.toBe(representation);
patch = getPatch('');
input = { identifier, patch };
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
await expect(arrayifyStream(result.data)).resolves.toEqual([]);
});
it('handles INSERT DATA updates.', async(): Promise<void> => {
const patch = getPatch(fulfilledDataInsert);
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
...startQuads,
quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')),
quad(namedNode('http://test.com/s2'), namedNode('http://test.com/p2'), namedNode('http://test.com/o2')),
]);
});
it('handles DELETE DATA updates.', async(): Promise<void> => {
const patch = getPatch('DELETE DATA { :startS1 :startP1 :startO1 }');
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
]);
});
it('handles DELETE WHERE updates with no variables.', async(): Promise<void> => {
const patch = getPatch('DELETE WHERE { :startS1 :startP1 :startO1 }');
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
]);
});
it('handles DELETE WHERE updates with variables.', async(): Promise<void> => {
const patch = getPatch('DELETE WHERE { :startS1 :startP1 ?o }');
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
]);
});
it('handles DELETE/INSERT updates with empty WHERE.', async(): Promise<void> => {
const patch = getPatch('DELETE { :startS1 :startP1 :startO1 } INSERT { :s1 :p1 :o1 . } WHERE {}');
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s1'),
namedNode('http://test.com/p1'),
namedNode('http://test.com/o1')),
]);
});
it('handles composite INSERT/DELETE updates.', async(): Promise<void> => {
const query = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 };' +
'DELETE WHERE { :s1 :p1 :o1 . :startS1 :startP1 :startO1 }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s2'),
namedNode('http://test.com/p2'),
namedNode('http://test.com/o2')),
]);
});
it('handles composite DELETE/INSERT updates.', async(): Promise<void> => {
const query = 'DELETE DATA { :s1 :p1 :o1 . :startS1 :startP1 :startO1 } ;' +
'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s1'),
namedNode('http://test.com/p1'),
namedNode('http://test.com/o1')),
quad(namedNode('http://test.com/s2'),
namedNode('http://test.com/p2'),
namedNode('http://test.com/o2')),
]);
});
it('rejects GRAPH inserts.', async(): Promise<void> => {
const query = 'INSERT DATA { GRAPH :graph { :s1 :p1 :o1 } }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects GRAPH deletes.', async(): Promise<void> => {
const query = 'DELETE DATA { GRAPH :graph { :s1 :p1 :o1 } }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects DELETE/INSERT updates with non-BGP WHERE.', async(): Promise<void> => {
const query = 'DELETE { :s1 :p1 :o1 } INSERT { :s1 :p1 :o1 } WHERE { ?s ?p ?o. FILTER (?o > 5) }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects INSERT WHERE updates with a UNION.', async(): Promise<void> => {
const query = 'INSERT { :s1 :p1 :o1 . } WHERE { { :s1 :p1 :o1 } UNION { :s1 :p1 :o2 } }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects non-DELETE/INSERT updates.', async(): Promise<void> => {
const query = 'MOVE DEFAULT TO GRAPH :newGraph';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('creates a new resource if it does not exist yet.', async(): Promise<void> => {
const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch };
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
expect(result.metadata.identifier.value).toBe(identifier.path);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')),
]);
});
it('requires the input body to contain quads.', async(): Promise<void> => {
const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }';
const patch = getPatch(query);
representation.metadata.contentType = 'text/turtle';
const input = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow('Quad stream was expected for patching.');
});
});