« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/data/cacheable-object.js313
-rw-r--r--src/data/thing.js11
-rw-r--r--src/data/things/index.js13
-rw-r--r--src/find.js4
-rw-r--r--src/listing-spec.js21
-rw-r--r--src/util/search-spec.js6
6 files changed, 169 insertions, 199 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index 774e695e..443dbc53 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -84,53 +84,21 @@ function inspect(value) {
 
 export default class CacheableObject {
   static propertyDescriptors = Symbol.for('CacheableObject.propertyDescriptors');
+  static constructorFinalized = Symbol.for('CacheableObject.constructorFinalized');
+  static propertyDependants = Symbol.for('CacheableObject.propertyDependants');
 
-  #propertyUpdateValues = Object.create(null);
-  #propertyUpdateCacheInvalidators = Object.create(null);
-
-  // Note the constructor doesn't take an initial data source. Due to a quirk
-  // of JavaScript, private members can't be accessed before the superclass's
-  // constructor is finished processing - so if we call the overridden
-  // update() function from inside this constructor, it will error when
-  // writing to private members. Pretty bad!
-  //
-  // That means initial data must be provided by following up with update()
-  // after constructing the new instance of the Thing (sub)class.
+  static cacheValid = Symbol.for('CacheableObject.cacheValid');
+  static updateValue = Symbol.for('CacheableObject.updateValues');
 
   constructor() {
-    this.#defineProperties();
-    this.#initializeUpdatingPropertyValues();
-
-    if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-      return new Proxy(this, {
-        get: (obj, key) => {
-          if (!Object.hasOwn(obj, key)) {
-            if (key !== 'constructor') {
-              CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`);
-            }
-          }
-          return obj[key];
-        },
-      });
-    }
-  }
-
-  #withEachPropertyDescriptor(callback) {
-    const {[CacheableObject.propertyDescriptors]: propertyDescriptors} =
-      this.constructor;
+    this[CacheableObject.updateValue] = Object.create(null);
+    this[CacheableObject.cachedValue] = Object.create(null);
+    this[CacheableObject.cacheValid] = Object.create(null);
 
+    const propertyDescriptors = this.constructor[CacheableObject.propertyDescriptors];
     for (const property of Reflect.ownKeys(propertyDescriptors)) {
-      callback(property, propertyDescriptors[property]);
-    }
-  }
-
-  #initializeUpdatingPropertyValues() {
-    this.#withEachPropertyDescriptor((property, descriptor) => {
-      const {flags, update} = descriptor;
-
-      if (!flags.update) {
-        return;
-      }
+      const {flags, update} = propertyDescriptors[property];
+      if (!flags.update) continue;
 
       if (
         typeof update === 'object' &&
@@ -141,188 +109,157 @@ export default class CacheableObject {
       } else {
         this[property] = null;
       }
-    });
+    }
   }
 
-  #defineProperties() {
-    if (!this.constructor[CacheableObject.propertyDescriptors]) {
-      throw new Error(`Expected constructor ${this.constructor.name} to provide CacheableObject.propertyDescriptors`);
+  static finalizeCacheableObjectPrototype() {
+    if (this[CacheableObject.constructorFinalized]) {
+      throw new Error(`Constructor ${this.name} already finalized`);
     }
 
-    this.#withEachPropertyDescriptor((property, descriptor) => {
-      const {flags} = descriptor;
+    if (!this[CacheableObject.propertyDescriptors]) {
+      throw new Error(`Expected constructor ${this.name} to provide CacheableObject.propertyDescriptors`);
+    }
+
+    this[CacheableObject.constructorFinalized] = true;
+    this[CacheableObject.propertyDependants] = Object.create(null);
+
+    const propertyDescriptors = this[CacheableObject.propertyDescriptors];
+    for (const property of Reflect.ownKeys(propertyDescriptors)) {
+      const {flags, update, expose} = propertyDescriptors[property];
 
       const definition = {
         configurable: false,
         enumerable: flags.expose,
       };
 
-      if (flags.update) {
-        definition.set = this.#getUpdateObjectDefinitionSetterFunction(property);
-      }
-
-      if (flags.expose) {
-        definition.get = this.#getExposeObjectDefinitionGetterFunction(property);
-      }
+      if (flags.update) setSetter: {
+        definition.set = function(newValue) {
+          if (newValue === undefined) {
+            throw new TypeError(`Properties cannot be set to undefined`);
+          }
 
-      Object.defineProperty(this, property, definition);
-    });
+          const oldValue = this[CacheableObject.updateValue][property];
 
-    Object.seal(this);
-  }
+          if (newValue === oldValue) {
+            return;
+          }
 
-  #getUpdateObjectDefinitionSetterFunction(property) {
-    const {update} = this.#getPropertyDescriptor(property);
-    const validate = update?.validate;
+          if (newValue !== null && update?.validate) {
+            try {
+              const result = update.validate(newValue);
+              if (result === undefined) {
+                throw new TypeError(`Validate function returned undefined`);
+              } else if (result !== true) {
+                throw new TypeError(`Validation failed for value ${newValue}`);
+              }
+            } catch (caughtError) {
+              throw new CacheableObjectPropertyValueError(
+                property, oldValue, newValue, {cause: caughtError});
+            }
+          }
 
-    return (newValue) => {
-      const oldValue = this.#propertyUpdateValues[property];
+          this[CacheableObject.updateValue][property] = newValue;
 
-      if (newValue === undefined) {
-        throw new TypeError(`Properties cannot be set to undefined`);
+          const dependants = this.constructor[CacheableObject.propertyDependants][property];
+          if (dependants) {
+            for (const dependant of dependants) {
+              this[CacheableObject.cacheValid][dependant] = false;
+            }
+          }
+        };
       }
 
-      if (newValue === oldValue) {
-        return;
-      }
+      if (flags.expose) setGetter: {
+        if (flags.update && !expose?.transform) {
+          definition.get = function() {
+            return this[CacheableObject.updateValue][property];
+          };
 
-      if (newValue !== null && validate) {
-        try {
-          const result = validate(newValue);
-          if (result === undefined) {
-            throw new TypeError(`Validate function returned undefined`);
-          } else if (result !== true) {
-            throw new TypeError(`Validation failed for value ${newValue}`);
-          }
-        } catch (caughtError) {
-          throw new CacheableObjectPropertyValueError(
-            property, oldValue, newValue, {cause: caughtError});
+          break setGetter;
         }
-      }
 
-      this.#propertyUpdateValues[property] = newValue;
-      this.#invalidateCachesDependentUpon(property);
-    };
-  }
-
-  #getPropertyDescriptor(property) {
-    return this.constructor[CacheableObject.propertyDescriptors][property];
-  }
+        if (flags.update && expose?.compute) {
+          throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
+        }
 
-  #invalidateCachesDependentUpon(property) {
-    const invalidators = this.#propertyUpdateCacheInvalidators[property];
-    if (!invalidators) {
-      return;
-    }
+        if (!flags.update && !expose?.compute) {
+          throw new Error(`Exposed property ${property} does not update and is missing compute function`);
+        }
 
-    for (const invalidate of invalidators) {
-      invalidate();
-    }
-  }
+        definition.get = function() {
+          if (this[CacheableObject.cacheValid][property]) {
+            return this[CacheableObject.cachedValue][property];
+          }
 
-  #getExposeObjectDefinitionGetterFunction(property) {
-    const {flags} = this.#getPropertyDescriptor(property);
-    const compute = this.#getExposeComputeFunction(property);
-
-    if (compute) {
-      let cachedValue;
-      const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
-      return () => {
-        if (checkCacheValid()) {
-          return cachedValue;
-        } else {
-          return (cachedValue = compute());
-        }
-      };
-    } else if (!flags.update && !compute) {
-      throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-    } else {
-      return () => this.#propertyUpdateValues[property];
-    }
-  }
+          this[CacheableObject.cacheValid][property] = true;
 
-  #getExposeComputeFunction(property) {
-    const {flags, expose} = this.#getPropertyDescriptor(property);
+          const dependencies = Object.create(null);
+          for (const key of expose.dependencies ?? []) {
+            switch (key) {
+              case 'this':
+                dependencies.this = this;
+                break;
 
-    const compute = expose?.compute;
-    const transform = expose?.transform;
+              case 'thisProperty':
+                dependencies.thisProperty = property;
+                break;
 
-    if (flags.update && !transform) {
-      return null;
-    } else if (flags.update && compute) {
-      throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
-    } else if (!flags.update && !compute) {
-      throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-    }
+              default:
+                dependencies[key] = this[CacheableObject.updateValue][key];
+                break;
+            }
+          }
 
-    let getAllDependencies;
+          const value =
+            (flags.update
+              ? expose.transform(this[CacheableObject.updateValue][property], dependencies)
+              : expose.compute(dependencies));
 
-    if (expose.dependencies?.length > 0) {
-      const dependencyKeys = expose.dependencies.slice();
-      const shouldReflectObject = dependencyKeys.includes('this');
-      const shouldReflectProperty = dependencyKeys.includes('thisProperty');
+          this[CacheableObject.cachedValue][property] = value;
 
-      getAllDependencies = () => {
-        const dependencies = Object.create(null);
+          return value;
+        };
+      }
 
-        for (const key of dependencyKeys) {
-          dependencies[key] = this.#propertyUpdateValues[key];
-        }
+      if (flags.expose) recordAsDependant: {
+        const dependantsMap = this[CacheableObject.propertyDependants];
 
-        if (shouldReflectObject) {
-          dependencies.this = this;
+        if (flags.update && expose?.transform) {
+          if (dependantsMap[property]) {
+            dependantsMap[property].push(property);
+          } else {
+            dependantsMap[property] = [property];
+          }
         }
 
-        if (shouldReflectProperty) {
-          dependencies.thisProperty = property;
+        for (const dependency of expose?.dependencies ?? []) {
+          switch (dependency) {
+            case 'this':
+            case 'thisProperty':
+              continue;
+
+            default: {
+              if (dependantsMap[dependency]) {
+                dependantsMap[dependency].push(property);
+              } else {
+                dependantsMap[dependency] = [property];
+              }
+            }
+          }
         }
+      }
 
-        return dependencies;
-      };
-    } else {
-      const dependencies = Object.create(null);
-      Object.freeze(dependencies);
-      getAllDependencies = () => dependencies;
-    }
-
-    if (flags.update) {
-      return () => transform(this.#propertyUpdateValues[property], getAllDependencies());
-    } else {
-      return () => compute(getAllDependencies());
+      Object.defineProperty(this.prototype, property, definition);
     }
   }
 
-  #getExposeCheckCacheValidFunction(property) {
-    const {flags, expose} = this.#getPropertyDescriptor(property);
-
-    let valid = false;
-
-    const invalidate = () => {
-      valid = false;
-    };
-
-    const dependencyKeys = new Set(expose?.dependencies);
-
-    if (flags.update) {
-      dependencyKeys.add(property);
-    }
-
-    for (const key of dependencyKeys) {
-      if (this.#propertyUpdateCacheInvalidators[key]) {
-        this.#propertyUpdateCacheInvalidators[key].push(invalidate);
-      } else {
-        this.#propertyUpdateCacheInvalidators[key] = [invalidate];
-      }
-    }
+  static getPropertyDescriptor(property) {
+    return this[CacheableObject.propertyDescriptors][property];
+  }
 
-    return () => {
-      if (!valid) {
-        valid = true;
-        return false;
-      } else {
-        return true;
-      }
-    };
+  static hasPropertyDescriptor(property) {
+    return Object.hasOwn(this[CacheableObject.propertyDescriptors], property);
   }
 
   static cacheAllExposedProperties(obj) {
@@ -368,11 +305,11 @@ export default class CacheableObject {
   }
 
   static getUpdateValue(object, key) {
-    if (!Object.hasOwn(object, key)) {
+    if (!object.constructor.hasPropertyDescriptor(key)) {
       return undefined;
     }
 
-    return object.#propertyUpdateValues[key] ?? null;
+    return object[CacheableObject.updateValue][key] ?? null;
   }
 
   static clone(object) {
@@ -384,7 +321,7 @@ export default class CacheableObject {
   }
 
   static copyUpdateValuesOnto(source, target) {
-    Object.assign(target, source.#propertyUpdateValues);
+    Object.assign(target, source[CacheableObject.updateValue]);
   }
 }
 
diff --git a/src/data/thing.js b/src/data/thing.js
index 4c3ba3e4..c51c5fe5 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -28,14 +28,13 @@ export default class Thing extends CacheableObject {
   // Symbol.for('Thing.isThingConstructor') in constructor
   static [Symbol.for('Thing.isThingConstructor')] = NaN;
 
-  static [CacheableObject.propertyDescriptors] = {
+  constructor() {
+    super();
+
     // To detect:
     // Object.hasOwn(object, Symbol.for('Thing.isThing'))
-    [Symbol.for('Thing.isThing')]: {
-      flags: {expose: true},
-      expose: {compute: () => NaN},
-    },
-  };
+    this[Symbol.for('Thing.isThing')] = NaN;
+  }
 
   static [Symbol.for('Thing.selectAll')] = _wikiData => [];
 
diff --git a/src/data/things/index.js b/src/data/things/index.js
index f18e283a..9f033c23 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -177,6 +177,16 @@ function evaluateSerializeDescriptors() {
   });
 }
 
+function finalizeCacheableObjectPrototypes() {
+  return descriptorAggregateHelper({
+    message: `Errors finalizing Thing class prototypes`,
+
+    op(constructor) {
+      constructor.finalizeCacheableObjectPrototype();
+    },
+  });
+}
+
 if (!errorDuplicateClassNames())
   process.exit(1);
 
@@ -188,6 +198,9 @@ if (!evaluatePropertyDescriptors())
 if (!evaluateSerializeDescriptors())
   process.exit(1);
 
+if (!finalizeCacheableObjectPrototypes())
+  process.exit(1);
+
 Object.assign(allClasses, {Thing});
 
 export default allClasses;
diff --git a/src/find.js b/src/find.js
index c7813e32..e590bc4f 100644
--- a/src/find.js
+++ b/src/find.js
@@ -34,7 +34,7 @@ export function processAvailableMatchesByName(data, {
   include = _thing => true,
 
   getMatchableNames = thing =>
-    (Object.hasOwn(thing, 'name')
+    (thing.constructor.hasPropertyDescriptor('name')
       ? [thing.name]
       : []),
 
@@ -72,7 +72,7 @@ export function processAvailableMatchesByDirectory(data, {
   include = _thing => true,
 
   getMatchableDirectories = thing =>
-    (Object.hasOwn(thing, 'directory')
+    (thing.constructor.hasPropertyDescriptor('directory')
       ? [thing.directory]
       : [null]),
 
diff --git a/src/listing-spec.js b/src/listing-spec.js
index bfea397c..749f009a 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -238,6 +238,27 @@ listingSpec.push({
   groupUnderOther: true,
 });
 
+// Dunkass mock. Listings should be Things! In the fuuuuture!
+class Listing {
+  static properties = {};
+
+  constructor() {
+    Object.assign(this, this.constructor.properties);
+  }
+
+  static hasPropertyDescriptor(key) {
+    return Object.hasOwn(this.properties, key);
+  }
+}
+
+for (const [index, listing] of listingSpec.entries()) {
+  class ListingSubclass extends Listing {
+    static properties = listing;
+  }
+
+  listingSpec.splice(index, 1, new ListingSubclass);
+}
+
 {
   const errors = [];
 
diff --git a/src/util/search-spec.js b/src/util/search-spec.js
index bc24e1a1..3d05c021 100644
--- a/src/util/search-spec.js
+++ b/src/util/search-spec.js
@@ -134,14 +134,14 @@ export const searchSpec = {
         thing.color;
 
       fields.artTags =
-        (Object.hasOwn(thing, 'artTags')
+        (thing.constructor.hasPropertyDescriptor('artTags')
           ? thing.artTags.map(artTag => artTag.nameShort)
           : []);
 
       fields.additionalNames =
-        (Object.hasOwn(thing, 'additionalNames')
+        (thing.constructor.hasPropertyDescriptor('additionalNames')
           ? thing.additionalNames.map(entry => entry.name)
-       : Object.hasOwn(thing, 'aliasNames')
+       : thing.constructor.hasPropertyDescriptor('aliasNames')
           ? thing.aliasNames
           : []);