« get me outta code hell

yaml: subdocuments (initial commit) - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2025-03-31 10:44:18 -0300
committer(quasar) nebula <qznebula@protonmail.com>2025-04-10 16:02:35 -0300
commit6eec0d5bc93c8e4d8c434500de028f508ba2a85b (patch)
tree4eccb7bbe7eac7035a5409bdfb20af365bd1bd9d /src/data
parent286b4ab5e14adf5c64d1044df4f75bfb6bd329f4 (diff)
yaml: subdocuments (initial commit)
Diffstat (limited to 'src/data')
-rw-r--r--src/data/yaml.js100
1 files changed, 98 insertions, 2 deletions
diff --git a/src/data/yaml.js b/src/data/yaml.js
index a5614ea6..518f57ac 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -13,6 +13,7 @@ import Thing from '#thing';
 import thingConstructors from '#things';
 
 import {
+  aggregateThrows,
   annotateErrorWithFile,
   decorateErrorWithIndex,
   decorateErrorWithAnnotation,
@@ -88,6 +89,10 @@ function makeProcessDocument(thingConstructor, {
   // A or B.
   //
   invalidFieldCombinations = [],
+
+  // Bouncing function used to process subdocuments: this is a function which
+  // in turn calls the appropriate *result of* makeProcessDocument.
+  processDocument: bouncer,
 }) {
   if (!thingConstructor) {
     throw new Error(`Missing Thing class`);
@@ -97,6 +102,10 @@ function makeProcessDocument(thingConstructor, {
     throw new Error(`Expected fields to be provided`);
   }
 
+  if (!bouncer) {
+    throw new Error(`Missing processDocument bouncer`);
+  }
+
   const knownFields = Object.keys(fieldSpecs);
 
   const ignoredFields =
@@ -144,6 +153,7 @@ function makeProcessDocument(thingConstructor, {
         : `document`);
 
     const aggregate = openAggregate({
+      ...aggregateThrows(ProcessDocumentError),
       message: `Errors processing ${constructorPart}` + namePart,
     });
 
@@ -194,13 +204,31 @@ function makeProcessDocument(thingConstructor, {
 
     const fieldValues = {};
 
+    const subdocSymbol = Symbol('subdoc');
+    const subdocSetups = {};
+
+    const transformUtilities = {
+      ...thingConstructors,
+
+      subdoc(documentType, data) {
+        if (!documentType)
+          throw new Error(`Expected document type, got ${typeAppearance(documentType)}`);
+        if (!data)
+          throw new Error(`Expected data, got ${typeAppearance(data)}`);
+        if (typeof data !== 'object' || data === null)
+          throw new Error(`Expected data to be an object, got ${data}`);
+
+        return {[subdocSymbol]: {documentType, data}};
+      },
+    };
+
     for (const [field, documentValue] of documentEntries) {
       if (skippedFields.has(field)) continue;
 
       // This variable would like to certify itself as "not into capitalism".
       let propertyValue =
         (fieldSpecs[field].transform
-          ? fieldSpecs[field].transform(documentValue)
+          ? fieldSpecs[field].transform(documentValue, transformUtilities)
           : documentValue);
 
       // Completely blank items in a YAML list are read as null.
@@ -223,9 +251,44 @@ function makeProcessDocument(thingConstructor, {
         }
       }
 
+      if (
+        typeof propertyValue === 'object' &&
+        propertyValue !== null &&
+        Object.hasOwn(propertyValue, subdocSymbol)
+      ) {
+        subdocSetups[field] = propertyValue[subdocSymbol];
+        continue;
+      }
+
       fieldValues[field] = propertyValue;
     }
 
+    const subdocErrors = [];
+    for (const [field, setup] of Object.entries(subdocSetups)) {
+      let subthing;
+      try {
+        const result = bouncer(setup.data, setup.documentType);
+        subthing = result.thing;
+        result.aggregate.close();
+      } catch (caughtError) {
+        if (!subthing) {
+          skippedFields.add(field);
+        }
+
+        subdocErrors.push(new SubdocError(
+          field, setup, {cause: caughtError}));
+      }
+
+      if (subthing) {
+        fieldValues[field] = subthing;
+      }
+    }
+
+    if (!empty(subdocErrors)) {
+      aggregate.push(new SubdocAggregateError(
+        subdocErrors, thingConstructor));
+    }
+
     const thing = Reflect.construct(thingConstructor, []);
 
     const fieldValueErrors = [];
@@ -260,6 +323,8 @@ function makeProcessDocument(thingConstructor, {
   });
 }
 
+export class ProcessDocumentError extends AggregateError {}
+
 export class UnknownFieldsError extends Error {
   constructor(fields) {
     super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`);
@@ -353,6 +418,37 @@ export class SkippedFieldsSummaryError extends Error {
   }
 }
 
+export class SubdocError extends Error {
+  constructor(field, setup, options) {
+    const fieldText =
+      colors.green(`"${field}"`);
+
+    const constructorText =
+      setup.documentType.name;
+
+    if (options.cause instanceof ProcessDocumentError) {
+      options.cause[Symbol.for('hsmusic.aggregate.translucent')] = true;
+    }
+
+    super(
+      `Errors processing ${constructorText} for ${fieldText} field`,
+      options);
+  }
+}
+
+export class SubdocAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+  constructor(errors, thingConstructor) {
+    const constructorText =
+      colors.green(thingConstructor.name);
+
+    super(
+      errors,
+      `Errors processing subdocuments for ${constructorText}`);
+  }
+}
+
 export function parseDate(date) {
   return new Date(date);
 }
@@ -899,7 +995,7 @@ export function processThingsFromDataStep(documents, dataStep) {
         throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
       }
 
-      fn = makeProcessDocument(thingClass, spec);
+      fn = makeProcessDocument(thingClass, {...spec, processDocument});
       submap.set(thingClass, fn);
     }