Repository: solid/community-server (ts)

Issue:
Containers should not list .acl and .meta files

Pull request:
Prevent metadata of auxiliary resources in container metadata
Closes #385 (again).

This reduces the performance for SPARQL backends due to the extra calls. This might actually increase the performance in some situations for file/memory backends.

Patch:
diff --git a/src/init/RootContainerInitializer.ts b/src/init/RootContainerInitializer.ts
index 060ee7488..6dff2ad7c 100644
--- a/src/init/RootContainerInitializer.ts
+++ b/src/init/RootContainerInitializer.ts
@@ -1,4 +1,3 @@
-import { DataFactory } from 'n3';
 import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
 import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata';
 import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
@@ -6,10 +5,9 @@ import { getLoggerFor } from '../logging/LogUtil';
 import type { ResourceStore } from '../storage/ResourceStore';
 import { TEXT_TURTLE } from '../util/ContentTypes';
 import { ensureTrailingSlash } from '../util/PathUtil';
-import { generateResourceQuads } from '../util/ResourceUtil';
+import { addResourceMetadata } from '../util/ResourceUtil';
 import { PIM, RDF } from '../util/Vocabularies';
 import { Initializer } from './Initializer';
-import namedNode = DataFactory.namedNode;
 
 /**
  * Initializes ResourceStores by creating a root container if it didn't exist yet.
@@ -43,7 +41,7 @@ export class RootContainerInitializer extends Initializer {
    */
   protected async createRootContainer(): Promise<void> {
     const metadata = new RepresentationMetadata(this.baseId, TEXT_TURTLE);
-    metadata.addQuads(generateResourceQuads(namedNode(this.baseId.path), true));
+    addResourceMetadata(metadata, true);
 
     // Make sure the root container is a pim:Storage
     // This prevents deletion of the root container as storage root containers can not be deleted
diff --git a/src/ldp/representation/RepresentationMetadata.ts b/src/ldp/representation/RepresentationMetadata.ts
index a3c495ef5..8e8f48e57 100644
--- a/src/ldp/representation/RepresentationMetadata.ts
+++ b/src/ldp/representation/RepresentationMetadata.ts
@@ -63,7 +63,7 @@ export class RepresentationMetadata {
   public constructor(contentType?: string);
 
   /**
-   * @param overrides - Metadata values (defaulting to content type if a string)
+   * @param metadata - Metadata values (defaulting to content type if a string)
    */
   public constructor(metadata?: RepresentationMetadata | MetadataRecord | string);
 
diff --git a/src/storage/DataAccessorBasedStore.ts b/src/storage/DataAccessorBasedStore.ts
index b6eaf3e70..5df621a50 100644
--- a/src/storage/DataAccessorBasedStore.ts
+++ b/src/storage/DataAccessorBasedStore.ts
@@ -25,7 +25,7 @@ import {
   toCanonicalUriPath,
 } from '../util/PathUtil';
 import { parseQuads } from '../util/QuadUtil';
-import { generateResourceQuads } from '../util/ResourceUtil';
+import { addResourceMetadata } from '../util/ResourceUtil';
 import { CONTENT_TYPE, DC, HTTP, LDP, POSIX, PIM, RDF, VANN, XSD } from '../util/Vocabularies';
 import type { DataAccessor } from './accessors/DataAccessor';
 import type { ResourceStore } from './ResourceStore';
@@ -94,9 +94,13 @@ export class DataAccessorBasedStore implements ResourceStore {
     await this.auxiliaryStrategy.addMetadata(metadata);
 
     if (isContainerPath(metadata.identifier.value)) {
-      // Remove containment references of auxiliary resources
-      const auxContains = this.getContainedAuxiliaryResources(metadata);
-      metadata.remove(LDP.terms.contains, auxContains);
+      // Add containment triples of non-auxiliary resources
+      for await (const child of this.accessor.getChildren(identifier)) {
+        if (!this.auxiliaryStrategy.isAuxiliaryIdentifier({ path: child.identifier.value })) {
+          metadata.addQuads(child.quads());
+          metadata.add(LDP.terms.contains, child.identifier as NamedNode);
+        }
+      }
 
       // Generate a container representation from the metadata
       const data = metadata.quads();
@@ -139,7 +143,7 @@ export class DataAccessorBasedStore implements ResourceStore {
     // using the HTTP Slug header as defined in [RFC5023].
     // Clients who want the server to assign a URI of a resource, MUST use the POST request."
     // https://solid.github.io/specification/protocol#resource-type-heuristics
-    const newID = this.createSafeUri(container, representation.metadata, parentMetadata);
+    const newID = await this.createSafeUri(container, representation.metadata);
 
     // Write the data. New containers should never be made for a POST request.
     await this.writeData(newID, representation, isContainerIdentifier(newID), false);
@@ -205,8 +209,7 @@ export class DataAccessorBasedStore implements ResourceStore {
     // https://solid.github.io/specification/protocol#deleting-resources
     if (isContainerIdentifier(identifier)) {
       // Auxiliary resources are not counted when deleting a container since they will also be deleted
-      const auxContains = this.getContainedAuxiliaryResources(metadata);
-      if (metadata.getAll(LDP.contains).length > auxContains.length) {
+      if (await this.hasProperChildren(identifier)) {
         throw new ConflictHttpError('Can only delete empty containers.');
       }
     }
@@ -290,7 +293,7 @@ export class DataAccessorBasedStore implements ResourceStore {
     // Need to do this before handling container data to have the correct identifier
     const { metadata } = representation;
     metadata.identifier = DataFactory.namedNode(identifier.path);
-    metadata.addQuads(generateResourceQuads(metadata.identifier, isContainer));
+    addResourceMetadata(metadata, isContainer);
 
     // Validate container data
     if (isContainer) {
@@ -399,10 +402,9 @@ export class DataAccessorBasedStore implements ResourceStore {
    *
    * @param container - Identifier of the target container.
    * @param metadata - Metadata of the new resource.
-   * @param parentMetadata - Metadata of the parent container.
    */
-  protected createSafeUri(container: ResourceIdentifier, metadata: RepresentationMetadata,
-    parentMetadata: RepresentationMetadata): ResourceIdentifier {
+  protected async createSafeUri(container: ResourceIdentifier, metadata: RepresentationMetadata):
+  Promise<ResourceIdentifier> {
     // Get all values needed for naming the resource
     const isContainer = this.isNewContainer(metadata);
     const slug = metadata.get(HTTP.slug)?.value;
@@ -418,11 +420,9 @@ export class DataAccessorBasedStore implements ResourceStore {
     }
 
     // Make sure we don't already have a resource with this exact name (or with differing trailing slash)
-    const withSlash = ensureTrailingSlash(newID.path);
-    const withoutSlash = trimTrailingSlashes(newID.path);
-    const exists = parentMetadata.getAll(LDP.contains).some((term): boolean =>
-      term.value === withSlash || term.value === withoutSlash);
-    if (exists) {
+    const withSlash = { path: ensureTrailingSlash(newID.path) };
+    const withoutSlash = { path: trimTrailingSlashes(newID.path) };
+    if (await this.resourceExists(withSlash) || await this.resourceExists(withoutSlash)) {
       newID = this.createURI(container, isContainer);
     }
 
@@ -458,11 +458,15 @@ export class DataAccessorBasedStore implements ResourceStore {
   }
 
   /**
-   * Extracts the identifiers of all auxiliary resources contained within the given metadata.
+   * Checks if the given container has any non-auxiliary resources.
    */
-  protected getContainedAuxiliaryResources(metadata: RepresentationMetadata): NamedNode[] {
-    return metadata.getAll(LDP.terms.contains).filter((object): boolean =>
-      this.auxiliaryStrategy.isAuxiliaryIdentifier({ path: object.value })) as NamedNode[];
+  protected async hasProperChildren(container: ResourceIdentifier): Promise<boolean> {
+    for await (const child of this.accessor.getChildren(container)) {
+      if (!this.auxiliaryStrategy.isAuxiliaryIdentifier({ path: child.identifier.value })) {
+        return true;
+      }
+    }
+    return false;
   }
 
   /**
diff --git a/src/storage/accessors/DataAccessor.ts b/src/storage/accessors/DataAccessor.ts
index 616fb2482..a197a5c1d 100644
--- a/src/storage/accessors/DataAccessor.ts
+++ b/src/storage/accessors/DataAccessor.ts
@@ -11,7 +11,7 @@ import type { Guarded } from '../../util/GuardedStream';
  *  * If the input identifier ends with a slash, it should be assumed the identifier is targeting a container.
  *  * Similarly, if there is no trailing slash it should assume a document.
  *  * It should always throw a NotFoundHttpError if it does not have data matching the input identifier.
- *  * DataAccessors are responsible for generating the relevant containment triples for containers.
+ *  * DataAccessors should not generate containment triples. This will be done externally using `getChildren`.
  */
 export interface DataAccessor {
   /**
@@ -36,6 +36,20 @@ export interface DataAccessor {
    */
   getMetadata: (identifier: ResourceIdentifier) => Promise<RepresentationMetadata>;
 
+  /**
+   * Returns metadata for all resources in the requested container.
+   * This should not be all metadata of those resources (but it can be),
+   * but instead the main metadata you want to show in situations
+   * where all these resources are presented simultaneously.
+   * Generally this would be metadata that is present for all of these resources,
+   * such as resource type or last modified date.
+   *
+   * It can be safely assumed that the incoming identifier will always correspond to a container.
+   *
+   * @param identifier - Identifier of the parent container.
+   */
+  getChildren: (identifier: ResourceIdentifier) => AsyncIterableIterator<RepresentationMetadata>;
+
   /**
    * Writes data and metadata for a document.
    * If any data and/or metadata exist for the given identifier, it should be overwritten.
diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts
index bdccaa674..98cc513a2 100644
--- a/src/storage/accessors/FileDataAccessor.ts
+++ b/src/storage/accessors/FileDataAccessor.ts
@@ -1,8 +1,7 @@
 import type { Stats } from 'fs';
 import { createWriteStream, createReadStream, promises as fsPromises } from 'fs';
 import type { Readable } from 'stream';
-import { DataFactory } from 'n3';
-import type { NamedNode, Quad } from 'rdf-js';
+import type { Quad } from 'rdf-js';
 import type { Representation } from '../../ldp/representation/Representation';
 import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
 import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
@@ -13,8 +12,8 @@ import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMedi
 import { guardStream } from '../../util/GuardedStream';
 import type { Guarded } from '../../util/GuardedStream';
 import { joinFilePath, isContainerIdentifier } from '../../util/PathUtil';
-import { parseQuads, pushQuad, serializeQuads } from '../../util/QuadUtil';
-import { generateContainmentQuads, generateResourceQuads } from '../../util/ResourceUtil';
+import { parseQuads, serializeQuads } from '../../util/QuadUtil';
+import { addResourceMetadata } from '../../util/ResourceUtil';
 import { toLiteral } from '../../util/TermUtil';
 import { CONTENT_TYPE, DC, LDP, POSIX, RDF, XSD } from '../../util/Vocabularies';
 import type { FileIdentifierMapper, ResourceLink } from '../mapping/FileIdentifierMapper';
@@ -70,6 +69,11 @@ export class FileDataAccessor implements DataAccessor {
     throw new NotFoundHttpError();
   }
 
+  public async* getChildren(identifier: ResourceIdentifier): AsyncIterableIterator<RepresentationMetadata> {
+    const link = await this.resourceMapper.mapUrlToFilePath(identifier);
+    yield* this.getChildMetadata(link);
+  }
+
   /**
    * Writes the given data as a file (and potential metadata as additional file).
    * The metadata file will be written first and will be deleted if something goes wrong writing the actual data.
@@ -194,8 +198,7 @@ export class FileDataAccessor implements DataAccessor {
    */
   private async getDirectoryMetadata(link: ResourceLink, stats: Stats):
   Promise<RepresentationMetadata> {
-    return (await this.getBaseMetadata(link, stats, true))
-      .addQuads(await this.getChildMetadataQuads(link));
+    return await this.getBaseMetadata(link, stats, true);
   }
 
   /**
@@ -247,8 +250,8 @@ export class FileDataAccessor implements DataAccessor {
   Promise<RepresentationMetadata> {
     const metadata = new RepresentationMetadata(link.identifier)
       .addQuads(await this.getRawMetadata(link.identifier));
-    metadata.addQuads(generateResourceQuads(metadata.identifier as NamedNode, isContainer));
-    metadata.addQuads(this.generatePosixQuads(metadata.identifier as NamedNode, stats));
+    addResourceMetadata(metadata, isContainer);
+    this.addPosixMetadata(metadata, stats);
     return metadata;
   }
 
@@ -277,63 +280,50 @@ export class FileDataAccessor implements DataAccessor {
   }
 
   /**
-   * Generate all containment related triples for a container.
-   * These include the actual containment triples and specific triples for every child resource.
+   * Generate metadata for all children in a container.
    *
    * @param link - Path related metadata.
    */
-  private async getChildMetadataQuads(link: ResourceLink): Promise<Quad[]> {
-    const quads: Quad[] = [];
-    const childURIs: string[] = [];
-    const files = await fsPromises.readdir(link.filePath);
+  private async* getChildMetadata(link: ResourceLink): AsyncIterableIterator<RepresentationMetadata> {
+    const dir = await fsPromises.opendir(link.filePath);
 
     // For every child in the container we want to generate specific metadata
-    for (const childName of files) {
-      // Hide metadata files from containment triples
+    for await (const entry of dir) {
+      const childName = entry.name;
+      // Hide metadata files
       if (this.isMetadataPath(childName)) {
         continue;
       }
 
       // Ignore non-file/directory entries in the folder
-      const childStats = await fsPromises.lstat(joinFilePath(link.filePath, childName));
-      if (!childStats.isFile() && !childStats.isDirectory()) {
+      if (!entry.isFile() && !entry.isDirectory()) {
         continue;
       }
 
       // Generate the URI corresponding to the child resource
       const childLink = await this.resourceMapper
-        .mapFilePathToUrl(joinFilePath(link.filePath, childName), childStats.isDirectory());
+        .mapFilePathToUrl(joinFilePath(link.filePath, childName), entry.isDirectory());
 
       // Generate metadata of this specific child
-      const subject = DataFactory.namedNode(childLink.identifier.path);
-      quads.push(...generateResourceQuads(subject, childStats.isDirectory()));
-      quads.push(...this.generatePosixQuads(subject, childStats));
-      childURIs.push(childLink.identifier.path);
+      const childStats = await fsPromises.lstat(joinFilePath(link.filePath, childName));
+      const metadata = new RepresentationMetadata(childLink.identifier);
+      addResourceMetadata(metadata, childStats.isDirectory());
+      this.addPosixMetadata(metadata, childStats);
+      yield metadata;
     }
-
-    // Generate containment metadata
-    const containsQuads = generateContainmentQuads(
-      DataFactory.namedNode(link.identifier.path), childURIs,
-    );
-
-    return quads.concat(containsQuads);
   }
 
   /**
    * Helper function to add file system related metadata.
-   * @param subject - Subject for the new quads.
+   * @param metadata - metadata object to add to
    * @param stats - Stats of the file/directory corresponding to the resource.
    */
-  private generatePosixQuads(subject: NamedNode, stats: Stats): Quad[] {
-    const quads: Quad[] = [];
-    pushQuad(quads, subject, DC.terms.modified, toLiteral(stats.mtime.toISOString(), XSD.terms.dateTime));
-    pushQuad(quads, subject, POSIX.terms.mtime, toLiteral(
-      Math.floor(stats.mtime.getTime() / 1000), XSD.terms.integer,
-    ));
+  private addPosixMetadata(metadata: RepresentationMetadata, stats: Stats): void {
+    metadata.add(DC.terms.modified, toLiteral(stats.mtime.toISOString(), XSD.terms.dateTime));
+    metadata.add(POSIX.terms.mtime, toLiteral(Math.floor(stats.mtime.getTime() / 1000), XSD.terms.integer));
     if (!stats.isDirectory()) {
-      pushQuad(quads, subject, POSIX.terms.size, toLiteral(stats.size, XSD.terms.integer));
+      metadata.add(POSIX.terms.size, toLiteral(stats.size, XSD.terms.integer));
     }
-    return quads;
   }
 
   /**
diff --git a/src/storage/accessors/InMemoryDataAccessor.ts b/src/storage/accessors/InMemoryDataAccessor.ts
index e748646b8..ca1c337b9 100644
--- a/src/storage/accessors/InMemoryDataAccessor.ts
+++ b/src/storage/accessors/InMemoryDataAccessor.ts
@@ -1,13 +1,11 @@
 import type { Readable } from 'stream';
 import arrayifyStream from 'arrayify-stream';
-import type { NamedNode } from 'rdf-js';
 import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
 import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
 import { InternalServerError } from '../../util/errors/InternalServerError';
 import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
 import type { Guarded } from '../../util/GuardedStream';
 import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy';
-import { generateContainmentQuads } from '../../util/ResourceUtil';
 import { guardedStreamFrom } from '../../util/StreamUtil';
 import type { DataAccessor } from './DataAccessor';
 
@@ -46,7 +44,15 @@ export class InMemoryDataAccessor implements DataAccessor {
 
   public async getMetadata(identifier: ResourceIdentifier): Promise<RepresentationMetadata> {
     const entry = this.getEntry(identifier);
-    return this.generateMetadata(entry);
+    return new RepresentationMetadata(entry.metadata);
+  }
+
+  public async* getChildren(identifier: ResourceIdentifier): AsyncIterableIterator<RepresentationMetadata> {
+    const entry = this.getEntry(identifier);
+    if (!this.isDataEntry(entry)) {
+      const childNames = Object.keys(entry.entries);
+      yield* childNames.map((name): RepresentationMetadata => new RepresentationMetadata({ path: name }));
+    }
   }
 
   public async writeDocument(identifier: ResourceIdentifier, data: Guarded<Readable>, metadata: RepresentationMetadata):
@@ -141,14 +147,4 @@ export class InMemoryDataAccessor implements DataAccessor {
     }
     return entry;
   }
-
-  private generateMetadata(entry: CacheEntry): RepresentationMetadata {
-    const metadata = new RepresentationMetadata(entry.metadata);
-    if (!this.isDataEntry(entry)) {
-      const childNames = Object.keys(entry.entries);
-      const quads = generateContainmentQuads(metadata.identifier as NamedNode, childNames);
-      metadata.addQuads(quads);
-    }
-    return metadata;
-  }
 }
diff --git a/src/storage/accessors/SparqlDataAccessor.ts b/src/storage/accessors/SparqlDataAccessor.ts
index d31125941..91d99989a 100644
--- a/src/storage/accessors/SparqlDataAccessor.ts
+++ b/src/storage/accessors/SparqlDataAccessor.ts
@@ -4,16 +4,15 @@ import { SparqlEndpointFetcher } from 'fetch-sparql-endpoint';
 import { DataFactory } from 'n3';
 import type { NamedNode, Quad } from 'rdf-js';
 import type {
-  ConstructQuery, GraphPattern,
+  ConstructQuery,
+  GraphPattern,
   GraphQuads,
   InsertDeleteOperation,
   SparqlGenerator,
   Update,
   UpdateOperation,
 } from 'sparqljs';
-import {
-  Generator,
-} from 'sparqljs';
+import { Generator } from 'sparqljs';
 import type { Representation } from '../../ldp/representation/Representation';
 import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
 import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
@@ -83,9 +82,7 @@ export class SparqlDataAccessor implements DataAccessor {
    */
   public async getMetadata(identifier: ResourceIdentifier): Promise<RepresentationMetadata> {
     const name = namedNode(identifier.path);
-    const query = isContainerIdentifier(identifier) ?
-      this.sparqlConstructContainer(name) :
-      this.sparqlConstruct(this.getMetadataNode(name));
+    const query = this.sparqlConstruct(this.getMetadataNode(name));
     const stream = await this.sendSparqlConstruct(query);
     const quads = await arrayifyStream(stream);
 
@@ -101,6 +98,15 @@ export class SparqlDataAccessor implements DataAccessor {
     return metadata;
   }
 
+  public async* getChildren(identifier: ResourceIdentifier): AsyncIterableIterator<RepresentationMetadata> {
+    // Only triples that have a container identifier as subject are the containment triples
+    const name = namedNode(identifier.path);
+    const stream = await this.sendSparqlConstruct(this.sparqlConstruct(name));
+    for await (const entry of stream) {
+      yield new RepresentationMetadata((entry as Quad).object as NamedNode);
+    }
+  }
+
   /**
    * Writes the given metadata for the container.
    */
@@ -186,23 +192,6 @@ export class SparqlDataAccessor implements DataAccessor {
     };
   }
 
-  private sparqlConstructContainer(name: NamedNode): ConstructQuery {
-    const pattern = quad(variable('s'), variable('p'), variable('o'));
-    return {
-      queryType: 'CONSTRUCT',
-      template: [ pattern ],
-      where: [{
-        type: 'union',
-        patterns: [
-          this.sparqlSelectGraph(name, [ pattern ]),
-          this.sparqlSelectGraph(this.getMetadataNode(name), [ pattern ]),
-        ],
-      }],
-      type: 'query',
-      prefixes: {},
-    };
-  }
-
   private sparqlSelectGraph(name: NamedNode, triples: Quad[]): GraphPattern {
     return {
       type: 'graph',
@@ -215,8 +204,8 @@ export class SparqlDataAccessor implements DataAccessor {
    * Creates an update query that overwrites the data and metadata of a resource.
    * If there are no triples we assume it's a container (so don't overwrite the main graph with containment triples).
    * @param name - Name of the resource to update.
-   * @param parent - Name of the parent to update the containment triples.
    * @param metadata - New metadata of the resource.
+   * @param parent - Name of the parent to update the containment triples.
    * @param triples - New data of the resource.
    */
   private sparqlInsert(name: NamedNode, metadata: RepresentationMetadata, parent?: NamedNode, triples?: Quad[]):
diff --git a/src/util/QuadUtil.ts b/src/util/QuadUtil.ts
index f432d82ff..bd706cd7f 100644
--- a/src/util/QuadUtil.ts
+++ b/src/util/QuadUtil.ts
@@ -1,32 +1,12 @@
 import type { Readable } from 'stream';
 import arrayifyStream from 'arrayify-stream';
 import type { ParserOptions } from 'n3';
-import { DataFactory, StreamParser, StreamWriter } from 'n3';
-import type { Literal, NamedNode, Quad } from 'rdf-js';
+import { StreamParser, StreamWriter } from 'n3';
+import type { Quad } from 'rdf-js';
 import streamifyArray from 'streamify-array';
 import type { Guarded } from './GuardedStream';
 import { pipeSafely } from './StreamUtil';
-import { toSubjectTerm, toPredicateTerm, toObjectTerm } from './TermUtil';
 
-/**
- * Generates a quad with the given subject/predicate/object and pushes it to the given array.
- */
-export function pushQuad(
-  quads: Quad[] | Readable,
-  subject: string | NamedNode,
-  predicate: string | NamedNode,
-  object: string | NamedNode | Literal,
-): void {
-  quads.push(DataFactory.quad(toSubjectTerm(subject), toPredicateTerm(predicate), toObjectTerm(object)));
-}
-
-/**
- * Helper function for serializing an array of quads, with as result a Readable object.
- * @param quads - The array of quads.
- * @param contentType - The content-type to serialize to.
- *
- * @returns The Readable object.
- */
 export function serializeQuads(quads: Quad[], contentType?: string): Guarded<Readable> {
   return pipeSafely(streamifyArray(quads), new StreamWriter({ format: contentType }));
 }
diff --git a/src/util/ResourceUtil.ts b/src/util/ResourceUtil.ts
index b93769c71..6a4d80607 100644
--- a/src/util/ResourceUtil.ts
+++ b/src/util/ResourceUtil.ts
@@ -1,42 +1,24 @@
 import arrayifyStream from 'arrayify-stream';
-import { DataFactory } from 'n3';
-import type { NamedNode, Quad } from 'rdf-js';
 import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
 import type { Representation } from '../ldp/representation/Representation';
 import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata';
-import { pushQuad } from './QuadUtil';
 import { guardedStreamFrom } from './StreamUtil';
 
 import { LDP, RDF } from './Vocabularies';
 
 /**
  * Helper function to generate type quads for a Container or Resource.
- * @param subject - Subject for the new quads.
+ * @param metadata - Metadata to add to.
  * @param isContainer - If the identifier corresponds to a container.
  *
  * @returns The generated quads.
  */
-export function generateResourceQuads(subject: NamedNode, isContainer: boolean): Quad[] {
-  const quads: Quad[] = [];
+export function addResourceMetadata(metadata: RepresentationMetadata, isContainer: boolean): void {
   if (isContainer) {
-    pushQuad(quads, subject, RDF.terms.type, LDP.terms.Container);
-    pushQuad(quads, subject, RDF.terms.type, LDP.terms.BasicContainer);
+    metadata.add(RDF.terms.type, LDP.terms.Container);
+    metadata.add(RDF.terms.type, LDP.terms.BasicContainer);
   }
-  pushQuad(quads, subject, RDF.terms.type, LDP.terms.Resource);
-
-  return quads;
-}
-
-/**
- * Helper function to generate the quads describing that the resource URIs are children of the container URI.
- * @param containerURI - The URI of the container.
- * @param childURIs - The URI of the child resources.
- *
- * @returns The generated quads.
- */
-export function generateContainmentQuads(containerURI: NamedNode, childURIs: string[]): Quad[] {
-  return new RepresentationMetadata(containerURI,
-    { [LDP.contains]: childURIs.map(DataFactory.namedNode) }).quads();
+  metadata.add(RDF.terms.type, LDP.terms.Resource);
 }
 
 /**


Test patch:
diff --git a/test/unit/storage/DataAccessorBasedStore.test.ts b/test/unit/storage/DataAccessorBasedStore.test.ts
index eb41b4b3a..405941d69 100644
--- a/test/unit/storage/DataAccessorBasedStore.test.ts
+++ b/test/unit/storage/DataAccessorBasedStore.test.ts
@@ -19,6 +19,7 @@ import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
 import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError';
 import type { Guarded } from '../../../src/util/GuardedStream';
 import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy';
+import { trimTrailingSlashes } from '../../../src/util/PathUtil';
 import * as quadUtil from '../../../src/util/QuadUtil';
 import { guardedStreamFrom } from '../../../src/util/StreamUtil';
 import { CONTENT_TYPE, HTTP, LDP, PIM, RDF } from '../../../src/util/Vocabularies';
@@ -56,6 +57,15 @@ class SimpleDataAccessor implements DataAccessor {
     return this.data[identifier.path].metadata;
   }
 
+  public async* getChildren(identifier: ResourceIdentifier): AsyncIterableIterator<RepresentationMetadata> {
+    // Find all keys that look like children of the container
+    const children = Object.keys(this.data).filter((name): boolean =>
+      name.startsWith(identifier.path) &&
+      name.length > identifier.path.length &&
+      !trimTrailingSlashes(name.slice(identifier.path.length)).includes('/'));
+    yield* children.map((name): RepresentationMetadata => new RepresentationMetadata({ path: name }));
+  }
+
   public async modifyResource(): Promise<void> {
     throw new Error('modify');
   }
@@ -100,7 +110,7 @@ class SimpleSuffixStrategy implements AuxiliaryStrategy {
   public async addMetadata(metadata: RepresentationMetadata): Promise<void> {
     const identifier = { path: metadata.identifier.value };
     // Random triple to test on
-    metadata.add(identifier.path, this.getAuxiliaryIdentifier(identifier).path);
+    metadata.add(namedNode('AUXILIARY'), this.getAuxiliaryIdentifier(identifier).path);
   }
 
   public async validate(): Promise<void> {
@@ -157,7 +167,7 @@ describe('A DataAccessorBasedStore', (): void => {
       expect(result).toMatchObject({ binary: true });
       expect(await arrayifyStream(result.data)).toEqual([ resourceData ]);
       expect(result.metadata.contentType).toEqual('text/plain');
-      expect(result.metadata.get(resourceID.path)?.value).toBe(auxStrategy.getAuxiliaryIdentifier(resourceID).path);
+      expect(result.metadata.get('AUXILIARY')?.value).toBe(auxStrategy.getAuxiliaryIdentifier(resourceID).path);
     });
 
     it('will return a data stream that matches the metadata for containers.', async(): Promise<void> => {
@@ -170,22 +180,20 @@ describe('A DataAccessorBasedStore', (): void => {
       expect(result).toMatchObject({ binary: false });
       expect(await arrayifyStream(result.data)).toBeRdfIsomorphic(metaMirror.quads());
       expect(result.metadata.contentType).toEqual(INTERNAL_QUADS);
-      expect(result.metadata.get(resourceID.path)?.value).toBe(auxStrategy.getAuxiliaryIdentifier(resourceID).path);
+      expect(result.metadata.get('AUXILIARY')?.value).toBe(auxStrategy.getAuxiliaryIdentifier(resourceID).path);
     });
 
     it('will remove containment triples referencing auxiliary resources.', async(): Promise<void> => {
       const resourceID = { path: `${root}container/` };
       containerMetadata.identifier = namedNode(resourceID.path);
-      containerMetadata.add(LDP.terms.contains, [
-        DataFactory.namedNode(`${root}container/.dummy`),
-        DataFactory.namedNode(`${root}container/resource`),
-        DataFactory.namedNode(`${root}container/resource.dummy`),
-      ]);
       accessor.data[resourceID.path] = { metadata: containerMetadata } as Representation;
+      accessor.data[`${resourceID.path}.dummy`] = representation;
+      accessor.data[`${resourceID.path}resource`] = representation;
+      accessor.data[`${resourceID.path}resource.dummy`] = representation;
       const result = await store.getRepresentation(resourceID);
       const contains = result.metadata.getAll(LDP.terms.contains);
       expect(contains).toHaveLength(1);
-      expect(contains[0].value).toEqual(`${root}container/resource`);
+      expect(contains[0].value).toEqual(`${resourceID.path}resource`);
     });
   });
 
@@ -280,7 +288,6 @@ describe('A DataAccessorBasedStore', (): void => {
       const resourceID = { path: root };
       representation.metadata.add(HTTP.slug, 'newName');
       accessor.data[`${root}newName`] = representation;
-      accessor.data[root].metadata.add(LDP.contains, DataFactory.namedNode(`${root}newName`));
       const result = await store.addResource(resourceID, representation);
       expect(result).not.toEqual({
         path: `${root}newName`,
@@ -522,7 +529,7 @@ describe('A DataAccessorBasedStore', (): void => {
 
     it('will error when deleting non-empty containers.', async(): Promise<void> => {
       accessor.data[`${root}container/`] = representation;
-      accessor.data[`${root}container/`].metadata.add(LDP.contains, DataFactory.namedNode(`${root}otherThing`));
+      accessor.data[`${root}container/otherThing`] = representation;
       const result = store.deleteResource({ path: `${root}container/` });
       await expect(result).rejects.toThrow(ConflictHttpError);
       await expect(result).rejects.toThrow('Can only delete empty containers.');
diff --git a/test/unit/storage/accessors/FileDataAccessor.test.ts b/test/unit/storage/accessors/FileDataAccessor.test.ts
index 7042d97a2..bc5858058 100644
--- a/test/unit/storage/accessors/FileDataAccessor.test.ts
+++ b/test/unit/storage/accessors/FileDataAccessor.test.ts
@@ -111,7 +111,7 @@ describe('A FileDataAccessor', (): void => {
       expect(metadata.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime));
     });
 
-    it('generates the metadata for a container and its non-meta children.', async(): Promise<void> => {
+    it('generates the metadata for a container.', async(): Promise<void> => {
       cache.data = { container: { resource: 'data', 'resource.meta': 'metadata', notAFile: 5, container2: {}}};
       metadata = await accessor.getMetadata({ path: `${base}container/` });
       expect(metadata.identifier.value).toBe(`${base}container/`);
@@ -121,18 +121,22 @@ describe('A FileDataAccessor', (): void => {
       expect(metadata.get(POSIX.size)).toBeUndefined();
       expect(metadata.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime));
       expect(metadata.get(POSIX.mtime)).toEqualRdfTerm(toLiteral(Math.floor(now.getTime() / 1000), XSD.terms.integer));
-      expect(metadata.getAll(LDP.contains)).toEqualRdfTermArray(
-        [ namedNode(`${base}container/resource`), namedNode(`${base}container/container2/`) ],
-      );
+    });
 
-      const childQuads = metadata.quads().filter((quad): boolean =>
-        quad.subject.value === `${base}container/resource`);
-      const childMetadata = new RepresentationMetadata({ path: `${base}container/resource` }).addQuads(childQuads);
-      expect(childMetadata.get(RDF.type)?.value).toBe(LDP.Resource);
-      expect(childMetadata.get(POSIX.size)).toEqualRdfTerm(toLiteral('data'.length, XSD.terms.integer));
-      expect(childMetadata.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime));
-      expect(childMetadata.get(POSIX.mtime)).toEqualRdfTerm(toLiteral(Math.floor(now.getTime() / 1000),
-        XSD.terms.integer));
+    it('generates metadata for container child resources.', async(): Promise<void> => {
+      cache.data = { container: { resource: 'data', 'resource.meta': 'metadata', notAFile: 5, container2: {}}};
+      const children = [];
+      for await (const child of accessor.getChildren({ path: `${base}container/` })) {
+        children.push(child);
+      }
+      expect(children).toHaveLength(2);
+      for (const child of children) {
+        expect([ `${base}container/resource`, `${base}container/container2/` ]).toContain(child.identifier.value);
+        expect(child.getAll(RDF.type)!.some((type): boolean => type.equals(LDP.terms.Resource))).toBe(true);
+        expect(child.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime));
+        expect(child.get(POSIX.mtime)).toEqualRdfTerm(toLiteral(Math.floor(now.getTime() / 1000),
+          XSD.terms.integer));
+      }
     });
 
     it('adds stored metadata when requesting metadata.', async(): Promise<void> => {
diff --git a/test/unit/storage/accessors/InMemoryDataAccessor.test.ts b/test/unit/storage/accessors/InMemoryDataAccessor.test.ts
index d8dc6d57c..f7e360210 100644
--- a/test/unit/storage/accessors/InMemoryDataAccessor.test.ts
+++ b/test/unit/storage/accessors/InMemoryDataAccessor.test.ts
@@ -1,6 +1,5 @@
 import 'jest-rdf';
 import type { Readable } from 'stream';
-import { DataFactory } from 'n3';
 import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
 import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
 import { InMemoryDataAccessor } from '../../../../src/storage/accessors/InMemoryDataAccessor';
@@ -85,16 +84,20 @@ describe('An InMemoryDataAccessor', (): void => {
       expect(metadata.quads()).toHaveLength(0);
     });
 
-    it('generates the containment metadata for a container.', async(): Promise<void> => {
+    it('generates the children for a container.', async(): Promise<void> => {
       await expect(accessor.writeContainer({ path: `${base}container/` }, metadata)).resolves.toBeUndefined();
       await expect(accessor.writeDocument({ path: `${base}container/resource` }, data, metadata))
         .resolves.toBeUndefined();
       await expect(accessor.writeContainer({ path: `${base}container/container2/` }, metadata))
         .resolves.toBeUndefined();
-      metadata = await accessor.getMetadata({ path: `${base}container/` });
-      expect(metadata.getAll(LDP.contains)).toEqualRdfTermArray(
-        [ DataFactory.namedNode(`${base}container/resource`), DataFactory.namedNode(`${base}container/container2/`) ],
-      );
+
+      const children = [];
+      for await (const child of accessor.getChildren({ path: `${base}container/` })) {
+        children.push(child);
+      }
+      expect(children).toHaveLength(2);
+      expect(children[0].identifier.value).toEqual(`${base}container/resource`);
+      expect(children[1].identifier.value).toEqual(`${base}container/container2/`);
     });
 
     it('adds stored metadata when requesting document metadata.', async(): Promise<void> => {
@@ -136,10 +139,16 @@ describe('An InMemoryDataAccessor', (): void => {
       metadata = await accessor.getMetadata(identifier);
       expect(metadata.identifier.value).toBe(`${base}container/`);
       const quads = metadata.quads();
-      expect(quads).toHaveLength(3);
+      expect(quads).toHaveLength(2);
       expect(metadata.getAll(RDF.type).map((term): string => term.value))
         .toEqual([ LDP.Container, LDP.BasicContainer ]);
-      expect(metadata.get(LDP.contains)?.value).toEqual(`${base}container/resource`);
+
+      const children = [];
+      for await (const child of accessor.getChildren({ path: `${base}container/` })) {
+        children.push(child);
+      }
+      expect(children).toHaveLength(1);
+      expect(children[0].identifier.value).toEqual(`${base}container/resource`);
 
       await expect(accessor.getMetadata({ path: `${base}container/resource` }))
         .resolves.toBeInstanceOf(RepresentationMetadata);
@@ -147,7 +156,7 @@ describe('An InMemoryDataAccessor', (): void => {
     });
 
     it('can write to the root container without overriding its children.', async(): Promise<void> => {
-      const identifier = { path: `${base}` };
+      const identifier = { path: base };
       const inputMetadata = new RepresentationMetadata(identifier, { [RDF.type]: LDP.terms.Container });
       await expect(accessor.writeContainer(identifier, inputMetadata)).resolves.toBeUndefined();
       const resourceMetadata = new RepresentationMetadata();
@@ -158,21 +167,39 @@ describe('An InMemoryDataAccessor', (): void => {
       metadata = await accessor.getMetadata(identifier);
       expect(metadata.identifier.value).toBe(`${base}`);
       const quads = metadata.quads();
-      expect(quads).toHaveLength(2);
+      expect(quads).toHaveLength(1);
       expect(metadata.getAll(RDF.type)).toHaveLength(1);
-      expect(metadata.getAll(LDP.contains)).toHaveLength(1);
+
+      const children = [];
+      for await (const child of accessor.getChildren(identifier)) {
+        children.push(child);
+      }
+      expect(children).toHaveLength(1);
+      expect(children[0].identifier.value).toEqual(`${base}resource`);
 
       await expect(accessor.getMetadata({ path: `${base}resource` }))
         .resolves.toBeInstanceOf(RepresentationMetadata);
       expect(await readableToString(await accessor.getData({ path: `${base}resource` }))).toBe('data');
     });
 
-    it('errors when writing to an invalid container path..', async(): Promise<void> => {
+    it('errors when writing to an invalid container path.', async(): Promise<void> => {
       await expect(accessor.writeDocument({ path: `${base}resource/` }, data, metadata)).resolves.toBeUndefined();
 
       await expect(accessor.writeContainer({ path: `${base}resource/container` }, metadata))
         .rejects.toThrow('Invalid path.');
     });
+
+    it('returns no children for documents.', async(): Promise<void> => {
+      const identifier = { path: `${base}resource` };
+      const inputMetadata = new RepresentationMetadata(identifier, { [RDF.type]: LDP.terms.Resource });
+      await expect(accessor.writeDocument(identifier, data, inputMetadata)).resolves.toBeUndefined();
+
+      const children = [];
+      for await (const child of accessor.getChildren(identifier)) {
+        children.push(child);
+      }
+      expect(children).toHaveLength(0);
+    });
   });
 
   describe('deleting a resource', (): void => {
diff --git a/test/unit/storage/accessors/SparqlDataAccessor.test.ts b/test/unit/storage/accessors/SparqlDataAccessor.test.ts
index f1742b55b..8ddb37dfa 100644
--- a/test/unit/storage/accessors/SparqlDataAccessor.test.ts
+++ b/test/unit/storage/accessors/SparqlDataAccessor.test.ts
@@ -103,7 +103,7 @@ describe('A SparqlDataAccessor', (): void => {
     ));
   });
 
-  it('requests container data for generating its metadata.', async(): Promise<void> => {
+  it('does not set the content-type for container metadata.', async(): Promise<void> => {
     metadata = await accessor.getMetadata({ path: 'http://container/' });
     expect(metadata.quads()).toBeRdfIsomorphic([
       quad(namedNode('this'), namedNode('a'), namedNode('triple')),
@@ -111,13 +111,25 @@ describe('A SparqlDataAccessor', (): void => {
 
     expect(fetchTriples).toHaveBeenCalledTimes(1);
     expect(fetchTriples.mock.calls[0][0]).toBe(endpoint);
-    expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery([
-      'CONSTRUCT { ?s ?p ?o. } WHERE {',
-      '  { GRAPH <http://container/> { ?s ?p ?o. } }',
-      '  UNION',
-      '  { GRAPH <meta:http://container/> { ?s ?p ?o. } }',
-      '}',
-    ]));
+    expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery(
+      'CONSTRUCT { ?s ?p ?o. } WHERE { GRAPH <meta:http://container/> { ?s ?p ?o. } }',
+    ));
+  });
+
+  it('requests the container data to find its children.', async(): Promise<void> => {
+    triples = [ quad(namedNode('http://container/'), LDP.terms.contains, namedNode('http://container/child')) ];
+    const children = [];
+    for await (const child of accessor.getChildren({ path: 'http://container/' })) {
+      children.push(child);
+    }
+    expect(children).toHaveLength(1);
+    expect(children[0].identifier.value).toBe('http://container/child');
+
+    expect(fetchTriples).toHaveBeenCalledTimes(1);
+    expect(fetchTriples.mock.calls[0][0]).toBe(endpoint);
+    expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery(
+      'CONSTRUCT { ?s ?p ?o. } WHERE { GRAPH <http://container/> { ?s ?p ?o. } }',
+    ));
   });
 
   it('throws 404 if no metadata was found.', async(): Promise<void> => {
diff --git a/test/unit/util/QuadUtil.test.ts b/test/unit/util/QuadUtil.test.ts
index d1e8d03b0..236b65c34 100644
--- a/test/unit/util/QuadUtil.test.ts
+++ b/test/unit/util/QuadUtil.test.ts
@@ -1,28 +1,9 @@
 import 'jest-rdf';
 import { literal, namedNode, quad } from '@rdfjs/data-model';
-import type { Quad } from 'rdf-js';
-import { parseQuads, pushQuad, serializeQuads } from '../../../src/util/QuadUtil';
+import { parseQuads, serializeQuads } from '../../../src/util/QuadUtil';
 import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil';
 
 describe('QuadUtil', (): void => {
-  describe('#pushQuad', (): void => {
-    it('creates a quad and adds it to the given array.', async(): Promise<void> => {
-      const quads: Quad[] = [];
-      pushQuad(quads, namedNode('sub'), namedNode('pred'), literal('obj'));
-      expect(quads).toEqualRdfQuadArray([
-        quad(namedNode('sub'), namedNode('pred'), literal('obj')),
-      ]);
-    });
-
-    it('creates a quad from strings and adds it to the given array.', async(): Promise<void> => {
-      const quads: Quad[] = [];
-      pushQuad(quads, 'sub', 'pred', 'obj');
-      expect(quads).toEqualRdfQuadArray([
-        quad(namedNode('sub'), namedNode('pred'), namedNode('obj')),
-      ]);
-    });
-  });
-
   describe('#serializeQuads', (): void => {
     it('converts quads to the requested format.', async(): Promise<void> => {
       const quads = [ quad(
diff --git a/test/util/Util.ts b/test/util/Util.ts
index 11d4f0325..e2f6b694f 100644
--- a/test/util/Util.ts
+++ b/test/util/Util.ts
@@ -1,4 +1,5 @@
-import type { Stats } from 'fs';
+import type { Dirent, Stats } from 'fs';
+
 import { PassThrough } from 'stream';
 import streamifyArray from 'streamify-array';
 import type { SystemError } from '../../src/util/errors/SystemError';
@@ -149,6 +150,19 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } {
         }
         return Object.keys(folder[name]);
       },
+      async* opendir(path: string): AsyncIterableIterator<Dirent> {
+        const { folder, name } = getFolder(path);
+        if (!folder[name]) {
+          throwSystemError('ENOENT');
+        }
+        for (const child of Object.keys(folder[name])) {
+          yield {
+            name: child,
+            isFile: (): boolean => typeof folder[name][child] === 'string',
+            isDirectory: (): boolean => typeof folder[name][child] === 'object',
+          } as Dirent;
+        }
+      },
       mkdir(path: string): void {
         const { folder, name } = getFolder(path);
         if (folder[name]) {