Repository: solid/community-server
Pull request #474 (2021-01-05 22:44:14)

Support writer prefixes
Closes https://github.com/solid/community-server/issues/470

Can't do it directly with the main serializer because of https://github.com/rubensworks/rdf-serialize.js/issues/1


Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index a9552b7e7..ec1427012 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -59,6 +59,8 @@ module.exports = {
     'new-cap': 'off',
     // Necessary in constructor overloading
     'no-param-reassign': 'off',
+    // Checked by @typescript-eslint/no-redeclare
+    'no-redeclare': 'off',
     // Conflicts with external libraries
     'no-underscore-dangle': 'off',
     // Already checked by @typescript-eslint/no-unused-vars
diff --git a/package-lock.json b/package-lock.json
index 07a134196..219f06291 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6803,9 +6803,9 @@
       "dev": true
     },
     "n3": {
-      "version": "1.6.4",
-      "resolved": "https://registry.npmjs.org/n3/-/n3-1.6.4.tgz",
-      "integrity": "sha512-qiiBhW2nJ59cfQzi0nvZs5tSXkXgDXedIy3zNNuKjTwE8Bcvv95DTFJpOY9geg6of5T7z6cg+ZWcaHIij3svrA==",
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/n3/-/n3-1.7.0.tgz",
+      "integrity": "sha512-8R0Qj545WnVLQxOfxxyFKzOpO13hF3jhSMJfO0FNqvbsPZDiR9ZDmGGjXAlcoZDf/88OsCYd7rHML284vm1h6A==",
       "requires": {
         "queue-microtask": "^1.1.2",
         "readable-stream": "^3.6.0"
@@ -7629,9 +7629,9 @@
       "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
     },
     "queue-microtask": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.0.tgz",
-      "integrity": "sha512-J95OVUiS4b8qqmpqhCodN8yPpHG2mpZUPQ8tDGyIY0VhM+kBHszOuvsMJVGNQ1OH2BnTFbqz45i+2jGpDw9H0w=="
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.2.tgz",
+      "integrity": "sha512-dB15eXv3p2jDlbOiNLyMabYg1/sXvppd8DP2J3EOCQ0AkuSXCW2tP7mnVouVLJKgUMY6yP0kcQDVpLCN13h4Xg=="
     },
     "quick-lru": {
       "version": "4.0.1",
diff --git a/package.json b/package.json
index f3e36ad26..d8545701f 100644
--- a/package.json
+++ b/package.json
@@ -94,7 +94,7 @@
     "fetch-sparql-endpoint": "^1.8.0",
     "handlebars": "^4.7.6",
     "mime-types": "^2.1.27",
-    "n3": "^1.6.4",
+    "n3": "^1.7.0",
     "rdf-parse": "^1.5.0",
     "rdf-serialize": "^1.0.0",
     "rdf-terms": "^1.5.1",
diff --git a/src/ldp/representation/RepresentationMetadata.ts b/src/ldp/representation/RepresentationMetadata.ts
index 890663116..4f9a5818e 100644
--- a/src/ldp/representation/RepresentationMetadata.ts
+++ b/src/ldp/representation/RepresentationMetadata.ts
@@ -1,7 +1,7 @@
 import { DataFactory, Store } from 'n3';
 import type { BlankNode, Literal, NamedNode, Quad, Term } from 'rdf-js';
 import { getLoggerFor } from '../../logging/LogUtil';
-import { toObjectTerm, toCachedNamedNode, isTerm } from '../../util/TermUtil';
+import { toSubjectTerm, toObjectTerm, toCachedNamedNode, isTerm } from '../../util/TermUtil';
 import { CONTENT_TYPE_TERM } from '../../util/Vocabularies';
 import type { ResourceIdentifier } from './ResourceIdentifier';
 import { isResourceIdentifier } from './ResourceIdentifier';
@@ -86,10 +86,15 @@ export class RepresentationMetadata {
   }
 
   /**
-   * @returns All metadata quads.
+   * @returns All matching metadata quads.
    */
-  public quads(): Quad[] {
-    return this.store.getQuads(null, null, null, null);
+  public quads(
+    subject: Term | null = null,
+    predicate: Term | null = null,
+    object: Term | null = null,
+    graph: Term | null = null,
+  ): Quad[] {
+    return this.store.getQuads(subject, predicate, object, graph);
   }
 
   /**
@@ -128,6 +133,18 @@ export class RepresentationMetadata {
     return this;
   }
 
+  /**
+   * @param quads - Quad to add to the metadata.
+   */
+  public addQuad(
+    subject: NamedNode | BlankNode | string,
+    predicate: NamedNode | string,
+    object: NamedNode | BlankNode | Literal | string,
+  ): this {
+    this.store.addQuad(toSubjectTerm(subject), toCachedNamedNode(predicate), toObjectTerm(object, true));
+    return this;
+  }
+
   /**
    * @param quads - Quads to add to the metadata.
    */
@@ -136,6 +153,18 @@ export class RepresentationMetadata {
     return this;
   }
 
+  /**
+   * @param quads - Quad to remove from the metadata.
+   */
+  public removeQuad(
+    subject: NamedNode | BlankNode | string,
+    predicate: NamedNode | string,
+    object: NamedNode | BlankNode | Literal | string,
+  ): this {
+    this.store.removeQuad(toSubjectTerm(subject), toCachedNamedNode(predicate), toObjectTerm(object, true));
+    return this;
+  }
+
   /**
    * @param quads - Quads to remove from the metadata.
    */
@@ -150,8 +179,7 @@ export class RepresentationMetadata {
    * @param object - Value to add.
    */
   public add(predicate: NamedNode | string, object: NamedNode | Literal | string): this {
-    this.store.addQuad(this.id, toCachedNamedNode(predicate), toObjectTerm(object, true));
-    return this;
+    return this.addQuad(this.id, predicate, object);
   }
 
   /**
@@ -160,8 +188,7 @@ export class RepresentationMetadata {
    * @param object - Value to remove.
    */
   public remove(predicate: NamedNode | string, object: NamedNode | Literal | string): this {
-    this.store.removeQuad(this.id, toCachedNamedNode(predicate), toObjectTerm(object, true));
-    return this;
+    return this.removeQuad(this.id, predicate, object);
   }
 
   /**
diff --git a/src/storage/conversion/QuadToRdfConverter.ts b/src/storage/conversion/QuadToRdfConverter.ts
index 356581885..595e33f7d 100644
--- a/src/storage/conversion/QuadToRdfConverter.ts
+++ b/src/storage/conversion/QuadToRdfConverter.ts
@@ -1,4 +1,5 @@
 import type { Readable } from 'stream';
+import { StreamWriter } from 'n3';
 import rdfSerializer from 'rdf-serialize';
 import type { Representation } from '../../ldp/representation/Representation';
 import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
@@ -8,7 +9,8 @@ import type {
 } from '../../ldp/representation/RepresentationPreferences';
 import { INTERNAL_QUADS } from '../../util/ContentTypes';
 import { guardStream } from '../../util/GuardedStream';
-import { CONTENT_TYPE } from '../../util/Vocabularies';
+import { pipeSafely } from '../../util/StreamUtil';
+import { CONTENT_TYPE, PREFERRED_PREFIX_TERM } from '../../util/Vocabularies';
 import { matchingMediaTypes } from './ConversionUtil';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
 import { TypedRepresentationConverter } from './TypedRepresentationConverter';
@@ -41,9 +43,20 @@ export class QuadToRdfConverter extends TypedRepresentationConverter {
   private async quadsToRdf(quads: Representation, { type }: RepresentationPreferences): Promise<Representation> {
     const contentType = matchingMediaTypes(type, await this.getOutputTypes())[0];
     const metadata = new RepresentationMetadata(quads.metadata, { [CONTENT_TYPE]: contentType });
+    let data: Readable;
+
+    // Use prefixes if possible (see https://github.com/rubensworks/rdf-serialize.js/issues/1)
+    if (/(?:turtle|trig)$/u.test(contentType)) {
+      const prefixes = Object.fromEntries(metadata.quads(null, PREFERRED_PREFIX_TERM, null)
+        .map(({ subject, object }): [string, string] => [ object.value, subject.value ]));
+      data = pipeSafely(quads.data, new StreamWriter({ format: contentType, prefixes }));
+    // Otherwise, write without prefixes
+    } else {
+      data = rdfSerializer.serialize(quads.data, { contentType }) as Readable;
+    }
     return {
       binary: true,
-      data: guardStream(rdfSerializer.serialize(quads.data, { contentType }) as Readable),
+      data: guardStream(data),
       metadata,
     };
   }
diff --git a/src/util/TermUtil.ts b/src/util/TermUtil.ts
index 51d5f6c1a..6308e6efc 100644
--- a/src/util/TermUtil.ts
+++ b/src/util/TermUtil.ts
@@ -1,5 +1,5 @@
 import { DataFactory } from 'n3';
-import type { Literal, NamedNode, Term } from 'rdf-js';
+import type { NamedNode, Literal, Term } from 'rdf-js';
 import { CONTENT_TYPE_TERM } from './Vocabularies';
 
 const { namedNode, literal } = DataFactory;
@@ -41,7 +41,10 @@ export function isTerm(input?: any): input is Term {
  * Converts a subject to a named node when needed.
  * @param subject - Subject to potentially transform.
  */
-export function toSubjectTerm(subject: NamedNode | string): NamedNode {
+export function toSubjectTerm(subject: string): NamedNode;
+export function toSubjectTerm<T extends Term>(subject: T): T;
+export function toSubjectTerm<T extends Term>(subject: T | string): T | NamedNode;
+export function toSubjectTerm(subject: Term | string): Term {
   return typeof subject === 'string' ? namedNode(subject) : subject;
 }
 
@@ -52,7 +55,10 @@ export const toPredicateTerm = toSubjectTerm;
  * @param object - Object to potentially transform.
  * @param preferLiteral - Whether strings are converted to literals or named nodes.
  */
-export function toObjectTerm<T extends Term>(object: T | string, preferLiteral = false): T {
+export function toObjectTerm(object: string, preferLiteral?: boolean): NamedNode;
+export function toObjectTerm<T extends Term>(object: T, preferLiteral?: boolean): T;
+export function toObjectTerm<T extends Term>(object: T | string, preferLiteral?: boolean): T | NamedNode;
+export function toObjectTerm(object: Term | string, preferLiteral = false): Term {
   if (typeof object === 'string') {
     return (preferLiteral ? literal(object) : namedNode(object)) as any;
   }
diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts
index 752b68c84..6e1b979d8 100644
--- a/src/util/Vocabularies.ts
+++ b/src/util/Vocabularies.ts
@@ -2,11 +2,10 @@
 import { namedNode } from '@rdfjs/data-model';
 import type { NamedNode } from 'rdf-js';
 
-type PrefixResolver<T> = (localName: string) => T;
 type RecordOf<TKey extends any[], TValue> = Record<TKey[number], TValue>;
 
 export type Namespace<TKey extends any[], TValue> =
-  PrefixResolver<TValue> & RecordOf<TKey, TValue>;
+  { namespace: TValue } & RecordOf<TKey, TValue>;
 
 /**
  * Creates a function that expands local names from the given base URI,
@@ -17,20 +16,14 @@ export function createNamespace<TKey extends string, TValue>(
   toValue: (expanded: string) => TValue,
   ...localNames: TKey[]):
   Namespace<typeof localNames, TValue> {
-  // Create a function that expands local names
-  const expanded = {} as Record<string, TValue>;
-  const namespace = ((localName: string): TValue => {
-    if (!(localName in expanded)) {
-      expanded[localName] = toValue(`${baseUri}${localName}`);
-    }
-    return expanded[localName];
-  }) as Namespace<typeof localNames, TValue>;
-
+  const expanded: Namespace<typeof localNames, TValue> = {} as any;
+  // Expose the main namespace
+  expanded.namespace = toValue(baseUri);
   // Expose the listed local names as properties
   for (const localName of localNames) {
-    (namespace as RecordOf<typeof localNames, TValue>)[localName] = namespace(localName);
+    (expanded as RecordOf<TKey[], TValue>)[localName] = toValue(`${baseUri}${localName}`);
   }
-  return namespace;
+  return expanded;
 }
 
 /**
@@ -114,11 +107,17 @@ export const RDF = createUriAndTermNamespace('http://www.w3.org/1999/02/22-rdf-s
   'type',
 );
 
+export const VANN = createUriAndTermNamespace('http://purl.org/vocab/vann/',
+  'preferredNamespacePrefix',
+);
+
 export const XSD = createUriAndTermNamespace('http://www.w3.org/2001/XMLSchema#',
   'dateTime',
   'integer',
 );
 
-// Alias for most commonly used URI
+// Alias for commonly used types
 export const CONTENT_TYPE = MA.format;
 export const CONTENT_TYPE_TERM = MA.terms.format;
+export const PREFERRED_PREFIX = VANN.preferredNamespacePrefix;
+export const PREFERRED_PREFIX_TERM = VANN.terms.preferredNamespacePrefix;


Test patch:
diff --git a/test/integration/LpdHandlerOperations.test.ts b/test/integration/LpdHandlerOperations.test.ts
index bf274e8d6..c086e44bc 100644
--- a/test/integration/LpdHandlerOperations.test.ts
+++ b/test/integration/LpdHandlerOperations.test.ts
@@ -107,12 +107,12 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
       [],
     );
     expect(response.statusCode).toBe(200);
-    expect(response._getBuffer().toString()).toContain(
+    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._getBuffer().toString());
+    const triples = parser.parse(response._getData());
     expect(triples).toBeRdfIsomorphic([
       quad(
         namedNode('http://test.com/s2'),
diff --git a/test/unit/ldp/representation/RepresentationMetadata.test.ts b/test/unit/ldp/representation/RepresentationMetadata.test.ts
index 9d87e35e8..a20d82d12 100644
--- a/test/unit/ldp/representation/RepresentationMetadata.test.ts
+++ b/test/unit/ldp/representation/RepresentationMetadata.test.ts
@@ -75,6 +75,11 @@ describe('A RepresentationMetadata', (): void => {
       expect(metadata.quads()).toBeRdfIsomorphic(inputQuads);
     });
 
+    it('can query quads.', async(): Promise<void> => {
+      expect(metadata.quads(null, namedNode('has'), null)).toHaveLength(3);
+      expect(metadata.quads(null, null, literal('otherData'))).toHaveLength(2);
+    });
+
     it('can change the stored identifier.', async(): Promise<void> => {
       const newIdentifier = namedNode('newNode');
       metadata.identifier = newIdentifier;
diff --git a/test/unit/storage/conversion/QuadToRdfConverter.test.ts b/test/unit/storage/conversion/QuadToRdfConverter.test.ts
index 5b8c6e429..275bc73e3 100644
--- a/test/unit/storage/conversion/QuadToRdfConverter.test.ts
+++ b/test/unit/storage/conversion/QuadToRdfConverter.test.ts
@@ -8,12 +8,16 @@ import type { RepresentationPreferences } from '../../../../src/ldp/representati
 import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
 import { QuadToRdfConverter } from '../../../../src/storage/conversion/QuadToRdfConverter';
 import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
-import { CONTENT_TYPE } from '../../../../src/util/Vocabularies';
+import { CONTENT_TYPE, DC, PREFERRED_PREFIX_TERM } from '../../../../src/util/Vocabularies';
 
 describe('A QuadToRdfConverter', (): void => {
   const converter = new QuadToRdfConverter();
   const identifier: ResourceIdentifier = { path: 'path' };
-  const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: INTERNAL_QUADS });
+  let metadata: RepresentationMetadata;
+
+  beforeEach((): void => {
+    metadata = new RepresentationMetadata({ [CONTENT_TYPE]: INTERNAL_QUADS });
+  });
 
   it('supports parsing quads.', async(): Promise<void> => {
     await expect(new QuadToRdfConverter().getInputTypes())
@@ -71,6 +75,33 @@ describe('A QuadToRdfConverter', (): void => {
     );
   });
 
+  it('converts quads with prefixes to turtle.', async(): Promise<void> => {
+    metadata.addQuad(DC.terms.namespace, PREFERRED_PREFIX_TERM, 'dc');
+    metadata.addQuad('http://test.com/', PREFERRED_PREFIX_TERM, 'test');
+    const representation = {
+      data: streamifyArray([ triple(
+        namedNode('http://test.com/s'),
+        DC.terms.modified,
+        namedNode('http://test.com/o'),
+      ) ]),
+      metadata,
+    } as Representation;
+    const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }};
+    const result = await converter.handle({ identifier, representation, preferences });
+    expect(result).toMatchObject({
+      binary: true,
+      metadata: expect.any(RepresentationMetadata),
+    });
+    expect(result.metadata.contentType).toEqual('text/turtle');
+    await expect(stringifyStream(result.data)).resolves.toEqual(
+      `@prefix dc: <http://purl.org/dc/terms/>.
+@prefix test: <http://test.com/>.
+
+test:s dc:modified test:o.
+`,
+    );
+  });
+
   it('converts quads to JSON-LD.', async(): Promise<void> => {
     metadata.contentType = INTERNAL_QUADS;
     const representation = {
diff --git a/test/unit/util/Vocabularies.test.ts b/test/unit/util/Vocabularies.test.ts
index 0885bea6d..c37c2b2a7 100644
--- a/test/unit/util/Vocabularies.test.ts
+++ b/test/unit/util/Vocabularies.test.ts
@@ -3,16 +3,12 @@ import { LDP } from '../../../src/util/Vocabularies';
 
 describe('Vocabularies', (): void => {
   describe('LDP', (): void => {
-    it('can create new properties.', (): void => {
-      expect(LDP('new')).toBe('http://www.w3.org/ns/ldp#new');
+    it('contains its own URI.', (): void => {
+      expect(LDP.namespace).toBe('http://www.w3.org/ns/ldp#');
     });
 
-    it('can create new properties as terms.', (): void => {
-      expect(LDP.terms('new')).toEqual(namedNode('http://www.w3.org/ns/ldp#new'));
-    });
-
-    it('caches new properties as terms.', (): void => {
-      expect(LDP.terms('new')).toBe(LDP.terms('new'));
+    it('contains its own URI as a term.', (): void => {
+      expect(LDP.terms.namespace).toEqual(namedNode('http://www.w3.org/ns/ldp#'));
     });
 
     it('exposes ldp:contains.', (): void => {
@@ -22,9 +18,5 @@ describe('Vocabularies', (): void => {
     it('exposes ldp:contains as a term.', (): void => {
       expect(LDP.terms.contains).toEqual(namedNode('http://www.w3.org/ns/ldp#contains'));
     });
-
-    it('caches ldp:contains as a term.', (): void => {
-      expect(LDP.terms.contains).toBe(LDP.terms.contains);
-    });
   });
 });