feat: Support composite PATCH updates

This commit is contained in:
Joachim Van Herwegen
2021-01-18 11:09:25 +01:00
parent e72117a21a
commit 36761e8124
5 changed files with 210 additions and 36 deletions

View File

@@ -22,8 +22,8 @@ export class SparqlPatchPermissionsExtractor extends PermissionsExtractor {
if (!this.isSparql(body)) {
throw new NotImplementedHttpError('Cannot determine permissions of non-SPARQL patches.');
}
if (!this.isDeleteInsert(body.algebra)) {
throw new NotImplementedHttpError('Cannot determine permissions of a PATCH without DELETE/INSERT.');
if (!this.isSupported(body.algebra)) {
throw new NotImplementedHttpError('Can only determine permissions of a PATCH with DELETE/INSERT operations.');
}
}
@@ -42,15 +42,33 @@ export class SparqlPatchPermissionsExtractor extends PermissionsExtractor {
return Boolean((data as SparqlUpdatePatch).algebra);
}
private isSupported(op: Algebra.Operation): boolean {
if (op.type === Algebra.types.DELETE_INSERT) {
return true;
}
if (op.type === Algebra.types.COMPOSITE_UPDATE) {
return (op as Algebra.CompositeUpdate).updates.every((update): boolean => this.isSupported(update));
}
return false;
}
private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
return op.type === Algebra.types.DELETE_INSERT;
}
private needsAppend(update: Algebra.DeleteInsert): boolean {
return Boolean(update.insert && update.insert.length > 0);
private needsAppend(update: Algebra.Operation): boolean {
if (this.isDeleteInsert(update)) {
return Boolean(update.insert && update.insert.length > 0);
}
return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.needsAppend(op));
}
private needsWrite(update: Algebra.DeleteInsert): boolean {
return Boolean(update.delete && update.delete.length > 0);
private needsWrite(update: Algebra.Operation): boolean {
if (this.isDeleteInsert(update)) {
return Boolean(update.delete && update.delete.length > 0);
}
return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.needsWrite(op));
}
}

View File

@@ -41,11 +41,43 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
// Verify the patch
const { identifier, patch } = input;
const op = patch.algebra;
if (!this.isDeleteInsert(op)) {
this.validateUpdate(op);
const lock = await this.locker.acquire(identifier);
try {
await this.applyPatch(identifier, op);
} finally {
await lock.release();
}
}
private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
return op.type === Algebra.types.DELETE_INSERT;
}
private isComposite(op: Algebra.Operation): op is Algebra.CompositeUpdate {
return op.type === Algebra.types.COMPOSITE_UPDATE;
}
/**
* Checks if the input operation is of a supported type (DELETE/INSERT or composite of those)
*/
private validateUpdate(op: Algebra.Operation): void {
if (this.isDeleteInsert(op)) {
this.validateDeleteInsert(op);
} else if (this.isComposite(op)) {
this.validateComposite(op);
} else {
this.logger.warn(`Unsupported operation: ${op.type}`);
throw new NotImplementedHttpError('Only DELETE/INSERT SPARQL update operations are supported');
}
}
/**
* Checks if the input DELETE/INSERT is supported.
* This means: no GRAPH statements, no DELETE WHERE.
*/
private validateDeleteInsert(op: Algebra.DeleteInsert): void {
const def = defaultGraph();
const deletes = op.delete ?? [];
const inserts = op.insert ?? [];
@@ -62,24 +94,21 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
this.logger.warn('WHERE statements are not supported');
throw new NotImplementedHttpError('WHERE statements are not supported');
}
const lock = await this.locker.acquire(identifier);
try {
await this.applyPatch(identifier, deletes, inserts);
} finally {
await lock.release();
}
}
private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
return op.type === Algebra.types.DELETE_INSERT;
}
/**
* Applies the given deletes and inserts to the resource.
* Checks if the composite update only contains supported update components.
*/
private async applyPatch(identifier: ResourceIdentifier, deletes: Algebra.Pattern[], inserts: Algebra.Pattern[]):
Promise<void> {
private validateComposite(op: Algebra.CompositeUpdate): void {
for (const update of op.updates) {
this.validateUpdate(update);
}
}
/**
* Apply the given algebra operation to the given identifier.
*/
private async applyPatch(identifier: ResourceIdentifier, op: Algebra.Operation): Promise<void> {
const store = new Store<BaseQuad>();
try {
// Read the quads of the current representation
@@ -100,13 +129,42 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
this.logger.debug(`Patching new resource ${identifier.path}.`);
}
// Apply the patch
store.removeQuads(deletes);
store.addQuads(inserts);
this.logger.debug(`Removed ${deletes.length} and added ${inserts.length} quads to ${identifier.path}.`);
this.applyOperation(store, op);
this.logger.debug(`${store.size} quads will be stored to ${identifier.path}.`);
// Write the result
await this.source.setRepresentation(identifier, new BasicRepresentation(store.match() as Readable, INTERNAL_QUADS));
}
/**
* Apply the given algebra update operation to the store of quads.
*/
private applyOperation(store: Store<BaseQuad>, op: Algebra.Operation): void {
if (this.isDeleteInsert(op)) {
this.applyDeleteInsert(store, op);
// Only other options is Composite after passing `validateUpdate`
} else {
this.applyComposite(store, op as Algebra.CompositeUpdate);
}
}
/**
* Apply the given composite update operation to the store of quads.
*/
private applyComposite(store: Store<BaseQuad>, op: Algebra.CompositeUpdate): void {
for (const update of op.updates) {
this.applyOperation(store, update);
}
}
/**
* Apply the given DELETE/INSERT update operation to the store of quads.
*/
private applyDeleteInsert(store: Store<BaseQuad>, op: Algebra.DeleteInsert): void {
const deletes = op.delete ?? [];
const inserts = op.insert ?? [];
store.removeQuads(deletes);
store.addQuads(inserts);
this.logger.debug(`Removed ${deletes.length} and added ${inserts.length} quads.`);
}
}

View File

@@ -98,21 +98,14 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
expect(response._getData()).toHaveLength(0);
// GET
requestUrl = new URL(id);
response = await performRequest(
handler,
requestUrl,
'GET',
{ accept: 'text/turtle' },
[],
);
response = await performRequest(handler, requestUrl, 'GET', { accept: 'text/turtle' }, []);
expect(response.statusCode).toBe(200);
expect(response._getData()).toContain(
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2>.',
);
expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
const parser = new Parser();
const triples = parser.parse(response._getData());
let triples = parser.parse(response._getData());
expect(triples).toBeRdfIsomorphic([
quad(
namedNode('http://test.com/s2'),
@@ -125,6 +118,36 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
namedNode('http://test.com/o3'),
),
]);
// PATCH
response = await performRequest(
handler,
requestUrl,
'PATCH',
{ 'content-type': 'application/sparql-update', 'transfer-encoding': 'chunked' },
[ 'DELETE DATA { <s2> <http://test.com/p2> <http://test.com/o2> }; ',
'INSERT DATA {<s4> <http://test.com/p4> <http://test.com/o4>}',
],
);
expect(response.statusCode).toBe(205);
expect(response._getData()).toHaveLength(0);
// GET
response = await performRequest(handler, requestUrl, 'GET', { accept: 'text/turtle' }, []);
expect(response.statusCode).toBe(200);
triples = parser.parse(response._getData());
expect(triples).toBeRdfIsomorphic([
quad(
namedNode('http://test.com/s3'),
namedNode('http://test.com/p3'),
namedNode('http://test.com/o3'),
),
quad(
namedNode('http://test.com/s4'),
namedNode('http://test.com/p4'),
namedNode('http://test.com/o4'),
),
]);
});
it('should overwrite the content on PUT request.', async(): Promise<void> => {

View File

@@ -8,9 +8,11 @@ describe('A SparqlPatchPermissionsExtractor', (): void => {
const extractor = new SparqlPatchPermissionsExtractor();
const factory = new Factory();
it('can only handle SPARQL DELETE/INSERT PATCH operations.', async(): Promise<void> => {
it('can only handle (composite) SPARQL DELETE/INSERT PATCH operations.', async(): Promise<void> => {
const operation = { method: 'PATCH', body: { algebra: factory.createDeleteInsert() }} as unknown as Operation;
await expect(extractor.canHandle(operation)).resolves.toBeUndefined();
(operation.body as SparqlUpdatePatch).algebra = factory.createCompositeUpdate([ factory.createDeleteInsert() ]);
await expect(extractor.canHandle(operation)).resolves.toBeUndefined();
await expect(extractor.canHandle({ ...operation, method: 'GET' }))
.rejects.toThrow(new BadRequestHttpError('Cannot determine permissions of GET, only PATCH.'));
await expect(extractor.canHandle({ ...operation, body: undefined }))
@@ -19,7 +21,8 @@ describe('A SparqlPatchPermissionsExtractor', (): void => {
.rejects.toThrow(new BadRequestHttpError('Cannot determine permissions of non-SPARQL patches.'));
await expect(extractor.canHandle({ ...operation,
body: { algebra: factory.createMove('DEFAULT', 'DEFAULT') } as unknown as SparqlUpdatePatch }))
.rejects.toThrow(new BadRequestHttpError('Cannot determine permissions of a PATCH without DELETE/INSERT.'));
.rejects
.toThrow(new BadRequestHttpError('Can only determine permissions of a PATCH with DELETE/INSERT operations.'));
});
it('requires append for INSERT operations.', async(): Promise<void> => {
@@ -49,4 +52,35 @@ describe('A SparqlPatchPermissionsExtractor', (): void => {
write: true,
});
});
it('requires append for composite operations with an insert.', async(): Promise<void> => {
const operation = {
method: 'PATCH',
body: { algebra: factory.createCompositeUpdate([ factory.createDeleteInsert(undefined, [
factory.createPattern(factory.createTerm('<s>'), factory.createTerm('<p>'), factory.createTerm('<o>')),
]) ]) },
} as unknown as Operation;
await expect(extractor.handle(operation)).resolves.toEqual({
read: false,
append: true,
write: false,
});
});
it('requires write for composite operations with a delete.', async(): Promise<void> => {
const operation = {
method: 'PATCH',
body: { algebra: factory.createCompositeUpdate([ factory.createDeleteInsert(undefined, [
factory.createPattern(factory.createTerm('<s>'), factory.createTerm('<p>'), factory.createTerm('<o>')),
]),
factory.createDeleteInsert([
factory.createPattern(factory.createTerm('<s>'), factory.createTerm('<p>'), factory.createTerm('<o>')),
]) ]) },
} as unknown as Operation;
await expect(extractor.handle(operation)).resolves.toEqual({
read: false,
append: true,
write: true,
});
});
});

View File

@@ -148,6 +148,47 @@ describe('A SparqlUpdatePatchHandler', (): void => {
])).toBe(true);
});
it('handles composite INSERT/DELETE updates.', async(): Promise<void> => {
await handler.handle({ identifier: { path: 'path' },
patch: { algebra: translate(
'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. ' +
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2> };' +
'DELETE WHERE { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>.' +
'<http://test.com/startS1> <http://test.com/startP1> <http://test.com/startO1> }',
{ quads: true },
) } as SparqlUpdatePatch });
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> => {
await handler.handle({ identifier: { path: 'path' },
patch: { algebra: translate(
'DELETE DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>.' +
'<http://test.com/startS1> <http://test.com/startP1> <http://test.com/startO1> };' +
'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. ' +
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2> }',
{ quads: true },
) } as SparqlUpdatePatch });
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 handle = handler.handle({ identifier: { path: 'path' },
patch: { algebra: translate(