diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js index 71dc5bded..901f755e6 100644 --- a/src/data/cacheable-object.js +++ b/src/data/cacheable-object.js @@ -85,6 +85,8 @@ function inspect(value) { export default class CacheableObject { static propertyDescriptors = Symbol.for('CacheableObject.propertyDescriptors'); + #propertyDescriptors = null; + #propertyUpdateValues = Object.create(null); #propertyUpdateCacheInvalidators = Object.create(null); @@ -97,7 +99,21 @@ export default class CacheableObject { // That means initial data must be provided by following up with update() // after constructing the new instance of the Thing (sub)class. - constructor() { + // It's possible to provide a different set of property descriptors than the + // ones that are just present on the constructor. + constructor(standinPropertyDescriptors = null) { + this.#propertyDescriptors = + (standinPropertyDescriptors + ? standinPropertyDescriptors + : this.constructor[CacheableObject.propertyDescriptors]); + + if (!this.#propertyDescriptors) { + throw new Error( + `Expected CacheableObject.propertyDescriptors` + + ` on constructor ${this.constructor.name},` + + ` or provided directly to CacheableObject`); + } + this.#defineProperties(); this.#initializeUpdatingPropertyValues(); @@ -116,11 +132,30 @@ export default class CacheableObject { } #withEachPropertyDescriptor(callback) { - const {[CacheableObject.propertyDescriptors]: propertyDescriptors} = - this.constructor; + const descriptorKeys = + this.#keysTilPrototype(this.#propertyDescriptors, Object.prototype); - for (const property of Reflect.ownKeys(propertyDescriptors)) { - callback(property, propertyDescriptors[property]); + for (const property of descriptorKeys) { + callback(property, this.#propertyDescriptors[property]); + } + } + + #keysTilPrototype(object, end) { + if (object === null) { + return new Set(); + } + + const prototypeKeys = + (Object.getPrototypeOf(object) === end + ? null + : this.#keysTilPrototype(Object.getPrototypeOf(object), end)); + + const ownKeys = Reflect.ownKeys(object); + + if (prototypeKeys) { + return new Set([...prototypeKeys, ...ownKeys]); + } else { + return new Set(ownKeys); } } @@ -145,10 +180,6 @@ export default class CacheableObject { } #defineProperties() { - if (!this.constructor[CacheableObject.propertyDescriptors]) { - throw new Error(`Expected constructor ${this.constructor.name} to provide CacheableObject.propertyDescriptors`); - } - this.#withEachPropertyDescriptor((property, descriptor) => { const {flags} = descriptor; @@ -165,7 +196,12 @@ export default class CacheableObject { definition.get = this.#getExposeObjectDefinitionGetterFunction(property); } - Object.defineProperty(this, property, definition); + try { + Object.defineProperty(this, property, definition); + } catch (error) { + console.log('I am:', this); + throw error; + } }); Object.seal(this); @@ -206,7 +242,7 @@ export default class CacheableObject { } #getPropertyDescriptor(property) { - return this.constructor[CacheableObject.propertyDescriptors][property]; + return this.#propertyDescriptors[property]; } #invalidateCachesDependentUpon(property) { @@ -331,8 +367,7 @@ export default class CacheableObject { return; } - const {[CacheableObject.propertyDescriptors]: propertyDescriptors} = - obj.constructor; + const propertyDescriptors = obj.#propertyDescriptors; if (!propertyDescriptors) { console.warn('Missing property descriptors:', obj); diff --git a/src/data/thing.js b/src/data/thing.js index 039465231..bf1fe3a36 100644 --- a/src/data/thing.js +++ b/src/data/thing.js @@ -36,6 +36,13 @@ export default class Thing extends CacheableObject { static [Symbol.for('Thing.selectAll')] = _wikiData => []; + // Magical constructor function that is the real entry point for, well, + // constructing any Thing subclass. Refer to the section about property + // descriptors later in this class for the high-level overview! + constructor() { + super(Thing.acquirePropertyDescriptors(new.target)); + } + // Default custom inspect function, which may be overridden by Thing // subclasses. This will be used when displaying aggregate errors and other // command-line logging - it's the place to provide information useful in @@ -78,32 +85,134 @@ export default class Thing extends CacheableObject { return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; } - static computePropertyDescriptors(constructor, { - thingConstructors, - }) { - if (!constructor[Thing.getPropertyDescriptors]) { - throw new Error(`Missing [Thing.getPropertyDescriptors] function`); + // The terminology around property descriptors is kind of pathetic, because + // there just aren't enough verbs! Here's the rundown: + // + // static Thing.getPropertyDescriptors: + // This is a *well-known symbol*. Subclasses use it to declare their + // property descriptors. + // + // static [Thing.getPropertyDescriptors](thingConstructors): + // This is a *static method* that subclasses of Thing define. It returns + // the property descriptors which are meaningful on that class, as well as + // its own subclasses (unless overridden). It takes thingConstructors like + // other utility functions - these are the identities of the constructors + // which its own property descriptors may access. + // + // static Thing.preparePropertyDescriptors(thingConstructors): + // This is a *static method* that Thing itself defines. It is a utility + // function which calls Thing.decidePropertyDescriptors on each of the + // provided constructors. + // + // static Thing.decidePropertyDescriptors(constructor, thingConstructors): + // This is a *static method* that Thing itself defines. It is a primitive + // function which calls Thing.computePropertyDescriptors and declares its + // result as the property descriptors which all instances of the provided + // Thing subclass will use. Before it is called, it's impossible to + // construct that particular subclass. Likewise, you can't ever call it + // again for the same constructor. + // + // static Thing.computePropertyDescriptors(constructor, thingConstructors): + // This is a *static method* that Thing itself defines. It is a primitive + // function that does some inheritence shenanigans to combine property + // descriptors statically defined on the provided Thing subclass as well + // as its superclasses, on both [CacheableObject.propertyDescriptors] and + // [Thing.getPropertyDescriptors], the latter of which it's responsible + // for calling. Unlike Thing.decidePropertyDescriptors, this function can + // be called any number of times for the same constructor - but it never + // actually stores anything to do with the constructor, so on its own it + // can only be used for introspection or "imagining" what a class would + // look like contextualized with different thingConstructors. + // + // static Thing.acquirePropertyDescriptors(constructor): + // This is a *static method* that Thing itself defines. It is a primitive + // function which gets the previously decided property descriptors to use + // for the provided Thing subclass. If it hasn't yet been decided, this + // throws an error. This is used when constructing instances of Thing + // subclasses, to ~get~ ~decide~ *acquire* the property descriptors which + // are provided to the CacheableObject super() constructing call. + // + // Kapiche? Nice! + + static #propertyDescriptorCache = new WeakMap(); + + static preparePropertyDescriptors(thingConstructors) { + for (const constructor of Object.values(thingConstructors)) { + Thing.decidePropertyDescriptors(constructor, thingConstructors); + } + } + + static decidePropertyDescriptors(constructor, thingConstructors) { + if (this.#propertyDescriptorCache.has(constructor)) { + throw new Error( + `Constructor ${constructor.name} has already had its property descriptors decided`); + } else { + this.#propertyDescriptorCache.set( + constructor, + this.computePropertyDescriptors(constructor, thingConstructors)); + } + } + + static computePropertyDescriptors(constructor, thingConstructors) { + let topOfChain = null; + + const superclass = + Object.getPrototypeOf(constructor) ?? null; + + if (superclass) { + const superDescriptors = + (superclass + ? Thing.computePropertyDescriptors(superclass, thingConstructors) + : null); + + topOfChain = superDescriptors; } - const results = - constructor[Thing.getPropertyDescriptors](thingConstructors); - - for (const [key, value] of Object.entries(results)) { - if (Array.isArray(value)) { - results[key] = compositeFrom({ - annotation: `${constructor.name}.${key}`, - compose: false, - steps: value, - }); - } else if (value.toResolvedComposition) { - results[key] = compositeFrom(value.toResolvedComposition()); + if (Object.hasOwn(constructor, CacheableObject.propertyDescriptors)) { + const classDescriptors = Object.create(topOfChain); + + Object.assign(classDescriptors, constructor[CacheableObject.propertyDescriptors]); + Object.seal(classDescriptors); + + topOfChain = classDescriptors; + } + + if (Object.hasOwn(constructor, Thing.getPropertyDescriptors)) { + const thingDescriptors = Object.create(topOfChain); + + const results = + constructor[Thing.getPropertyDescriptors](thingConstructors); + + for (const [key, value] of Object.entries(results)) { + if (Array.isArray(value)) { + results[key] = + compositeFrom({ + annotation: `${constructor.name}.${key}`, + compose: false, + steps: value, + }); + } else if (value.toResolvedComposition) { + results[key] = + compositeFrom(value.toResolvedComposition()); + } } + + Object.assign(thingDescriptors, results); + Object.seal(thingDescriptors); + + topOfChain = thingDescriptors; } - return { - ...constructor[CacheableObject.propertyDescriptors] ?? {}, - ...results, - }; + return topOfChain; + } + + static acquirePropertyDescriptors(constructor) { + if (this.#propertyDescriptorCache.has(constructor)) { + return this.#propertyDescriptorCache.get(constructor); + } else { + throw new Error( + `Constructor ${constructor.name} never had its property descriptors decided`); + } } static extendDocumentSpec(thingClass, subspec) { diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index 00d6aef5d..af6e05f4a 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -99,9 +99,7 @@ export class HomepageLayoutRow extends Thing { export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { static [Thing.friendlyName] = `Homepage Albums Row`; - static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ - ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), - + static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ // Update & expose type: { diff --git a/src/data/things/index.js b/src/data/things/index.js index edfb887e6..67de777c4 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -123,10 +123,7 @@ function evaluatePropertyDescriptors() { message: `Errors evaluating Thing class property descriptors`, op(constructor) { - constructor[CacheableObject.propertyDescriptors] = - Thing.computePropertyDescriptors(constructor, { - thingConstructors: allClasses, - }); + Thing.decidePropertyDescriptors(constructor, allClasses); }, showFailedClasses(failedClasses) { diff --git a/src/data/things/listing.js b/src/data/things/listing.js index 87e0f5400..509ce77bc 100644 --- a/src/data/things/listing.js +++ b/src/data/things/listing.js @@ -1,3 +1,5 @@ +import {inspect} from 'node:util'; + import {input} from '#composite'; import Thing from '#thing'; import {isStringNonEmpty} from '#validators'; @@ -70,6 +72,17 @@ export class Listing extends Thing { // Expose only + data: { + flags: {expose: true}, + expose: { + dependencies: ['this'], + compute: ({this: myself}) => { + console.warn(`${inspect(myself)} - "data" not implemented yet`); + return []; + }, + }, + }, + indexListing: [ { dependencies: ['directory'], diff --git a/src/listings.js b/src/listings.js index db89be8c6..d79654648 100644 --- a/src/listings.js +++ b/src/listings.js @@ -1,10 +1,15 @@ import {mapAggregate, withAggregate} from '#aggregate'; +import CacheableObject from '#cacheable-object'; import listingSpec, {listingTargetSpec} from '#listing-spec'; import Thing from '#thing'; import thingConstructors from '#things'; const {Listing} = thingConstructors; +function nameClass(cls, name) { + Object.defineProperty(cls, 'name', {value: name}); +} + export function getTargetFromListingSpec(spec) { if (!spec.target) { return null; @@ -15,58 +20,58 @@ export function getTargetFromListingSpec(spec) { .find(target => target.target === spec.target)); } -export function getPropertyDescriptorsFromListingSpec(spec) { - const allDescriptors = {}; - - const applyDescriptors = ({name, object}) => { - if (!object?.[Thing.getPropertyDescriptors]) return; - - const constructorLike = { - name, - - [Thing.getPropertyDescriptors]: - object[Thing.getPropertyDescriptors], - }; - - Object.assign(allDescriptors, - Thing.computePropertyDescriptors(constructorLike, { - thingConstructors, - })); - }; - +export function createClassFromListingSpec(spec) { const listingName = `(listing:${spec.directory})`; const listingTargetName = `(listing-target:${spec.target})`; - applyDescriptors({ - name: Listing.name, - object: Listing, - }); + let topOfChain = Listing; - applyDescriptors({ - name: listingTargetName, - object: getTargetFromListingSpec(spec), - }); + const target = getTargetFromListingSpec(spec); - applyDescriptors({ - name: listingName, - object: spec, - }); + if (target) { + const listingTargetClass = + class extends topOfChain { + static { + nameClass(this, listingTargetName); + } - if (spec.data) { - applyDescriptors({ - name: listingName, - object: { - [Thing.getPropertyDescriptors]: opts => ({ - data: spec.data(opts), - }), - }, - }); + static [Thing.getPropertyDescriptors](opts) { + return target[Thing.getPropertyDescriptors]?.(opts) ?? {}; + } + }; + + topOfChain = listingTargetClass; } - return allDescriptors; + const listingClass = + class extends topOfChain { + static { + // Note that this'll get deliberately overwritten soon, though only + // after we've made the call to Thing.decidePropertyDescriptors. + nameClass(this, listingName); + } + + static [Thing.getPropertyDescriptors](opts) { + const descriptors = {}; + + if (spec[Thing.getPropertyDescriptors]) { + Object.assign(descriptors, spec[Thing.getPropertyDescriptors](opts)); + } + + if (spec.data) { + descriptors.data = spec.data(opts); + } + + return descriptors; + } + }; + + Thing.decidePropertyDescriptors(listingClass, thingConstructors); + + return listingClass; } export function constructListingFromSpec(spec) { @@ -77,14 +82,14 @@ export function constructListingFromSpec(spec) { push(new Error(`Unknown target "${spec.target}"`)); } - const listingClass = class extends Listing { - static propertyDescriptors = - getPropertyDescriptorsFromListingSpec(spec); - }; + const listingClass = createClassFromListingSpec(spec); - Object.defineProperty(listingClass, 'name', { - value: Listing.name, - }); + // Rename the listing after the fact. LOL. + // It's useful to give it a custom name so that compositional properties + // are easier to identify (they're annotated according to the constructor + // name at the time). But when actually presenting the Listing instance + // itself, it should be called Listing. + nameClass(listingClass, Listing.name); const listing = new listingClass();