From 6483809c6d9c67f1311a64f2572b4fe5881d3a0d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 22 Aug 2023 22:36:20 -0300 Subject: data: composition docs, annotations, nesting --- src/data/things/thing.js | 316 +++++++++++++++++++++++++++++++++++++++++++++-- src/data/things/track.js | 81 +++++++----- 2 files changed, 359 insertions(+), 38 deletions(-) (limited to 'src') diff --git a/src/data/things/thing.js b/src/data/things/thing.js index f1ae6c71..1186c389 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -426,14 +426,222 @@ export default class Thing extends CacheableObject { } static composite = { - from(composition) { + // Composes multiple compositional "steps" and a "base" to form a property + // descriptor out of modular building blocks. This is an extension to the + // more general-purpose CacheableObject property descriptor syntax, and + // aims to make modular data processing - which lends to declarativity - + // much easier, without fundamentally altering much of the typical syntax + // or terminology, nor building on it to an excessive degree. + // + // Think of a composition as being a chain of steps which lead into a final + // base property, which is usually responsible for returning the value that + // will actually get exposed when the property being described is accessed. + // + // == The compositional base: == + // + // The final item in a compositional list is its base, and it identifies + // the essential qualities of the property descriptor. The compositional + // steps preceding it may exit early, in which case the expose function + // defined on the base won't be called; or they will provide dependencies + // that the base may use to compute the final value that gets exposed for + // this property. + // + // The base indicates the capabilities of the composition as a whole. + // It should be {expose: true}, since that's the only area that preceding + // compositional steps (currently) can actually influence. If it's also + // {update: true}, then the composition as a whole accepts an update value + // just like normal update-flag property descriptors - meaning it can be + // set with `thing.someProperty = value` and that value will be paseed + // into each (implementing) step's transform() function, as well as the + // base. Bases usually aren't {compose: true}, but can be - check out the + // section on "nesting compositions" for details about that. + // + // Every composition always has exactly one compositional base, and it's + // always the last item in the composition list. All items preceding it + // are compositional steps, described below. + // + // == Compositional steps: == + // + // Compositional steps are, in essence, typical property descriptors with + // the extra flag {compose: true}. They operate on existing dependencies, + // and are typically dynamically constructed by "utility" functions (but + // can also be manually declared within the step list of a composition). + // Compositional steps serve two purposes: + // + // 1. exit early, if some condition is matched, returning and exposing + // some value directly from that step instead of continuing further + // down the step list; + // + // 2. and/or provide new, dynamically created "private" dependencies which + // can be accessed by further steps down the list, or at the base at + // the bottom, modularly supplying information that will contribute to + // the final value exposed for this property. + // + // Usually it's just one of those two, but it's fine for a step to perform + // both jobs if the situation benefits. + // + // Compositional steps are the real "modular" or "compositional" part of + // this data processing style - they're designed to be combined together + // in dynamic, versatile ways, as each property demands it. You usually + // define a compositional step to be returned by some ordinary static + // property-descriptor-returning function (customarily namespaced under + // the relevant Thing class's static `composite` field) - that lets you + // reuse it in multiple compositions later on. + // + // Compositional steps are implemented with "continuation passing style", + // meaning the connection to the next link on the chain is passed right to + // each step's compute (or transform) function, and the implementation gets + // to decide whether to continue on that chain or exit early by returning + // some other value. + // + // Every step along the chain, apart from the base at the bottom, has to + // have the {compose: true} step. That means its compute() or transform() + // function will be passed an extra argument at the end, `continuation`. + // To provide new dependencies to items further down the chain, just pass + // them directly to this continuation() function, customarily with a hash + // ('#') prefixing each name - for example: + // + // compute({..some dependencies..}, continuation) { + // return continuation({ + // '#excitingProperty': (..a value made from dependencies..), + // }); + // } + // + // Performing an early exit is as simple as returning some other value, + // instead of the continuation. + // + // It may be fine to simply provide new dependencies under a hard-coded + // name, such as '#excitingProperty' above, but if you're writing a utility + // that dynamically returns the compositional step and you suspect you + // might want to use this step multiple times in a single composition, + // it's customary to accept a name for the result. + // + // Here's a detailed example showing off early exit, dynamically operating + // on a provided dependency name, and then providing a result in another + // also-provided dependency name: + // + // static Thing.composite.withResolvedContribs = ({ + // from: contribsByRefDependency, + // to: outputDependency, + // }) => ({ + // flags: {expose: true, compose: true}, + // expose: { + // dependencies: [contribsByRefDependency, 'artistData'], + // compute({ + // [contribsByRefDependency]: contribsByRef, + // artistData, + // }, continuation) { + // if (!artistData) return null; /* early exit! */ + // return continuation({ + // [outputDependency]: /* this is the important part */ + // (..resolve contributions one way or another..), + // }); + // }, + // }, + // }); + // + // And how you might work that into a composition: + // + // static Track[Thing.getPropertyDescriptors].coverArtists = + // Thing.composite.from([ + // Track.composite.doSomethingWhichMightEarlyExit(), + // Thing.composite.withResolvedContribs({ + // from: 'coverArtistContribsByRef', + // to: '#coverArtistContribs', + // }), + // + // { + // flags: {expose: true}, + // expose: { + // dependencies: ['#coverArtistContribs'], + // compute({'#coverArtistContribs': coverArtistContribs}) { + // return coverArtistContribs.map(({who}) => who); + // }, + // }, + // }, + // ]); + // + // == To compute or to transform: == + // + // A compositional step can work directly on a property's stored update + // value, transforming it in place and either early exiting with it or + // passing it on (via continuation) to the next item(s) in the + // compositional step list. (If needed, these can provide dependencies + // the same way as compute functions too - just pass that object after + // the updated (or same) transform value in your call to continuation().) + // + // But in order to make them more versatile, compositional steps have an + // extra trick up their sleeve. If a compositional step implements compute + // and *not* transform, it can still be used in a composition targeting a + // property which updates! These retain their full dependency-providing and + // early exit functionality - they just won't be provided the update value. + // If a compute-implementing step returns its continuation, then whichever + // later step (or the base) next implements transform() will receive the + // update value that had so far been running - as well as any dependencies + // the compute() step returned, of course! + // + // Please note that a compositional step which transforms *should not* + // specify, in its flags, {update: true}. Just provide the transform() + // function in its expose descriptor; it will be automatically detected + // and used when appropriate. + // + // It's actually possible for a step to specify both transform and compute, + // in which case the transform() implementation will only be selected if + // the composition's base is {update: true}. It's not exactly known why you + // would want to specify unique-but-related transform and compute behavior, + // but the basic possibility was too cool to skip out on. + // + // == Nesting compositions: == + // + // Compositional steps are so convenient that you just might want to bundle + // them together, and form a whole new step-shaped unit of its own! + // + // In order to allow for this while helping to ensure internal dependencies + // remain neatly isolated from the composition which nests your bundle, + // the Thing.composite.from() function will accept and adapt to a base that + // specifies the {compose: true} flag, just like the steps preceding it. + // + // The continuation function that gets provided to the base will be mildly + // special - after all, nothing follows the base within the composition's + // own list! Instead of appending dependencies alongside any previously + // provided ones to be available to the next step, the base's continuation + // function should be used to define "exports" of the composition as a + // whole. It's similar to the usual behavior of the continuation, just + // expanded to the scope of the composition instead of following steps. + // + // For example, suppose your composition (which you expect to include in + // other compositions) brings about several internal, hash-prefixed + // dependencies to contribute to its own results. Those dependencies won't + // end up "bleeding" into the dependency list of whichever composition is + // nesting this one - they will totally disappear once all the steps in + // the nested composition have finished up. + // + // To "export" the results of processing all those dependencies (provided + // that's something you want to do and this composition isn't used purely + // for a conditional early-exit), you'll want to define them in the + // continuation passed to the base. (Customarily, those should start with + // a hash just like the exports from any other compositional step; they're + // still dynamically provided dependencies!) + // + from(firstArg, secondArg) { + let annotation, composition; + if (typeof firstArg === 'string') { + [annotation, composition] = [firstArg, secondArg]; + } else { + [annotation, composition] = [null, firstArg]; + } + const base = composition.at(-1); const steps = composition.slice(0, -1); - const aggregate = openAggregate({message: `Errors preparing Thing.composite.from() composition`}); + const aggregate = openAggregate({ + message: + `Errors preparing Thing.composite.from() composition` + + (annotation ? ` (${annotation})` : ''), + }); - if (base.flags.compose) { - aggregate.push(new TypeError(`Base (bottom item) must not be {compose: true}`)); + if (base.flags.compose && base.flags.compute) { + push(new TypeError(`Base which composes can't also update yet`)); } const exposeFunctionOrder = []; @@ -500,14 +708,18 @@ export default class Thing extends CacheableObject { const constructedDescriptor = {}; + if (annotation) { + constructedDescriptor.annotation = annotation; + } + constructedDescriptor.flags = { update: !!base.flags.update, expose: !!base.flags.expose, - compose: false, + compose: !!base.flags.compose, }; if (base.flags.update) { - constructedDescriptor.update = base.flags.update; + constructedDescriptor.update = base.update; } if (base.flags.expose) { @@ -547,6 +759,9 @@ export default class Thing extends CacheableObject { const filteredDependencies = filterProperties(dependencies, base.expose.dependencies); + // Note: base.flags.compose is not compatible with base.flags.update, + // so the base.flags.compose case is not handled here. + if (base.expose.transform) { return base.expose.transform(valueSoFar, filteredDependencies); } else { @@ -554,7 +769,7 @@ export default class Thing extends CacheableObject { } }; } else { - expose.compute = (initialDependencies) => { + expose.compute = (initialDependencies, continuationIfApplicable) => { const dependencies = {...initialDependencies}; for (const {fn} of exposeFunctionOrder) { @@ -569,7 +784,23 @@ export default class Thing extends CacheableObject { } } - return base.expose.compute(dependencies); + if (base.flags.compose) { + let exportDependencies; + + const result = + base.expose.compute(dependencies, providedDependencies => { + exportDependencies = providedDependencies; + return continuationSymbol; + }); + + if (result !== continuationSymbol) { + return result; + } + + return exportDependencies; + } else { + return base.expose.compute(dependencies); + } }; } } @@ -577,11 +808,48 @@ export default class Thing extends CacheableObject { return constructedDescriptor; }, + // Provides dependencies exactly as they are (or null if not defined) to the + // continuation. Although this can *technically* be used to alias existing + // dependencies to some other name within the middle of a composition, it's + // intended to be used only as a composition's base - doing so makes the + // composition as a whole suitable as a step in some other composition, + // providing the listed (internal) dependencies to later steps just like + // other compositional steps. + export(mapping) { + const mappingEntries = Object.entries(mapping); + + return { + annotation: `Thing.composite.export`, + flags: {expose: true, compose: true}, + + expose: { + dependencies: Object.values(mapping), + + compute(dependencies, continuation) { + const exports = {}; + + // Note: This is slightly different behavior from filterProperties, + // as defined in sugar.js, which doesn't fall back to null for + // properties which don't exist on the original object. + for (const [exportKey, dependencyKey] of mappingEntries) { + exports[exportKey] = + (Object.hasOwn(dependencies, dependencyKey) + ? dependencies[dependencyKey] + : null); + } + + return continuation(exports); + } + }, + }; + }, + // Resolves the contribsByRef contained in the provided dependency, // providing (named by the second argument) the result. "Resolving" // means mapping the "who" reference of each contribution to an artist // object, and filtering out those whose "who" doesn't match any artist. withResolvedContribs: ({from: contribsByRefDependency, to: outputDependency}) => ({ + annotation: `Thing.composite.withResolvedContribs`, flags: {expose: true, compose: true}, expose: { @@ -593,5 +861,37 @@ export default class Thing extends CacheableObject { }), }, }), + + // Resolves a reference by using the provided find function to match it + // within the provided thingData dependency. This will early exit if the + // data dependency is null, or, if earlyExitIfNotFound is set to true, + // if the find function doesn't match anything for the reference. + // Otherwise, the data object (or null, if not found) is provided on + // the output dependency. + withResolvedReference({ + ref: refDependency, + data: dataDependency, + to: outputDependency, + find: findFunction, + earlyExitIfNotFound = false, + }) { + return { + annotation: `Thing.composite.withResolvedReference`, + flags: {expose: true, compose: true}, + + expose: { + dependencies: [refDependency, dataDependency], + + compute({[refDependency]: ref, [dataDependency]: data}, continuation) { + if (data === null) return null; + + const match = findFunction(ref, data, {mode: 'quiet'}); + if (match === null && earlyExitIfNotFound) return null; + + return continuation({[outputDependency]: match}); + }, + }, + }; + } }; } diff --git a/src/data/things/track.js b/src/data/things/track.js index 985de594..718eb07e 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -44,7 +44,7 @@ export class Track extends Thing { sampledTracksByRef: Thing.common.referenceList(Track), artTagsByRef: Thing.common.referenceList(ArtTag), - color: Thing.composite.from([ + color: Thing.composite.from(`Track.color`, [ { flags: {expose: true, compose: true}, expose: { @@ -77,7 +77,7 @@ export class Track extends Thing { // track's unique cover artwork, if any, and does not inherit the cover's // main artwork. (It does inherit `trackCoverArtFileExtension` if present // on the album.) - coverArtFileExtension: Thing.composite.from([ + coverArtFileExtension: Thing.composite.from(`Track.coverArtFileExtension`, [ Track.composite.withAlbumProperties({ properties: [ 'trackCoverArtistContribsByRef', @@ -114,7 +114,7 @@ export class Track extends Thing { // only the track's own unique cover artwork, if any. This exposes only as // the track's own coverArtDate or its album's trackArtDate, so if neither // is specified, this value is null. - coverArtDate: Thing.composite.from([ + coverArtDate: Thing.composite.from(`Track.coverArtDate`, [ Track.composite.withAlbumProperties({ properties: [ 'trackArtDate', @@ -192,7 +192,7 @@ export class Track extends Thing { find.album ), - date: Thing.composite.from([ + date: Thing.composite.from(`Track.date`, [ { flags: {expose: true, compose: true}, expose: { @@ -222,7 +222,7 @@ export class Track extends Thing { // or a placeholder. (This property is named hasUniqueCoverArt instead of // the usual hasCoverArt to emphasize that it does not inherit from the // album.) - hasUniqueCoverArt: Thing.composite.from([ + hasUniqueCoverArt: Thing.composite.from(`Track.hasUniqueCoverArt`, [ Track.composite.withAlbumProperties({ properties: ['trackCoverArtistContribsByRef'], }), @@ -284,7 +284,7 @@ export class Track extends Thing { }, }, - artistContribs: Thing.composite.from([ + artistContribs: Thing.composite.from(`Track.artistContribs`, [ Track.composite.inheritFromOriginalRelease('artistContribs'), Track.composite.withAlbumProperties({ @@ -311,7 +311,7 @@ export class Track extends Thing { }, ]), - contributorContribs: Thing.composite.from([ + contributorContribs: Thing.composite.from(`Track.contributorContribs`, [ Track.composite.inheritFromOriginalRelease('contributorContribs'), Thing.common.dynamicContribs('contributorContribsByRef'), ]), @@ -319,7 +319,7 @@ export class Track extends Thing { // Cover artists aren't inherited from the original release, since it // typically varies by release and isn't defined by the musical qualities // of the track. - coverArtistContribs: Thing.composite.from([ + coverArtistContribs: Thing.composite.from(`Track.coverArtistContribs`, [ { flags: {expose: true, compose: true}, expose: { @@ -355,12 +355,12 @@ export class Track extends Thing { }, ]), - referencedTracks: Thing.composite.from([ + referencedTracks: Thing.composite.from(`Track.referencedTracks`, [ Track.composite.inheritFromOriginalRelease('referencedTracks'), Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track), ]), - sampledTracks: Thing.composite.from([ + sampledTracks: Thing.composite.from(`Track.sampledTracks`, [ Track.composite.inheritFromOriginalRelease('sampledTracks'), Thing.common.dynamicThingsFromReferenceList('sampledTracksByRef', 'trackData', find.track), ]), @@ -417,37 +417,39 @@ export class Track extends Thing { }); static composite = { - // Returns a value inherited from the original release, if this track - // is a rerelease, and otherwise continues with no further provided - // dependencies. If the second argument is provided true, then the - // continuation will also be called if the original release exposed - // the requested property as null. - inheritFromOriginalRelease: (originalProperty, allowOverride = false) => ({ - flags: {expose: true, compose: true}, + // Early exits with a value inherited from the original release, if + // this track is a rerelease, and otherwise continues with no further + // dependencies provided. If allowOverride is true, then the continuation + // will also be called if the original release exposed the requested + // property as null. + inheritFromOriginalRelease: ({property: originalProperty, allowOverride = false}) => + Thing.composite.from(`Track.composite.inheritFromOriginalRelease`, [ + Track.composite.withOriginalRelease({to: '#originalRelease'}), - expose: { - dependencies: ['originalReleaseTrackByRef', 'trackData'], + { + flags: {expose: true, compose: true}, - compute({originalReleaseTrackByRef, trackData}, continuation) { - if (!originalReleaseTrackByRef) return continuation(); + expose: { + dependencies: ['#originalRelease'], - if (!trackData) return null; - const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'}); - if (!original) return null; + compute({'#originalRelease': originalRelease}, continuation) { + if (!originalRelease) return continuation(); - const value = original[originalProperty]; - if (allowOverride && value === null) return continuation(); + const value = originalRelease[originalProperty]; + if (allowOverride && value === null) return continuation(); - return value; - }, - }, - }), + return value; + }, + }, + } + ]), // Gets the listed properties from this track's album, providing them as // dependencies (by default) with '#album.' prefixed before each property // name. If the track's album isn't available, the same dependency names // will each be provided as null. withAlbumProperties: ({properties, prefix = '#album'}) => ({ + annotation: `Track.composite.withAlbumProperties`, flags: {expose: true, compose: true}, expose: { @@ -468,6 +470,25 @@ export class Track extends Thing { }, }, }), + + // Just includes the original release of this track as a dependency, or + // null, if it's not a rerelease. Note that this will early exit if the + // original release is specified by reference and that reference doesn't + // resolve to anything. + withOriginalRelease: ({to: outputDependency = '#originalRelease'}) => + Thing.composite.from(`Track.composite.withOriginalRelease`, [ + Thing.composite.withResolvedReference({ + ref: 'originalReleaseTrackByRef', + data: 'trackData', + to: '#originalRelease', + find: find.track, + earlyExitIfNotFound: true, + }), + + Thing.composite.export({ + [outputDependency]: '#originalRelease', + }), + ]), }; [inspect.custom]() { -- cgit 1.3.0-6-gf8a5