diff options
-rw-r--r-- | src/thing/album.js | 21 | ||||
-rw-r--r-- | src/thing/structures.js | 13 | ||||
-rw-r--r-- | src/util/sugar.js | 85 |
3 files changed, 108 insertions, 11 deletions
diff --git a/src/thing/album.js b/src/thing/album.js index be37489d..e99cfc36 100644 --- a/src/thing/album.js +++ b/src/thing/album.js @@ -1,6 +1,7 @@ import Thing from './thing.js'; import { + validateDirectory, validateReference } from './structures.js'; @@ -10,22 +11,33 @@ import { } from '../util/sugar.js'; export default class Album extends Thing { + #directory = null; #tracks = []; static updateError = { + directory: Thing.extendPropertyError('directory'), tracks: Thing.extendPropertyError('tracks') }; update(source) { - withAggregate(({ wrap, call, map }) => { - if (source.tracks) { - this.#tracks = map(source.tracks, t => validateReference('track')(t) && t, { - errorClass: this.constructor.updateError.tracks + const err = this.constructor.updateError; + + withAggregate(({ nest, filter, throws }) => { + + if (source.directory) { + nest(throws(err.directory), ({ call }) => { + if (call(validateDirectory, source.directory)) { + this.#directory = source.directory; + } }); } + + if (source.tracks) + this.#tracks = filter(source.tracks, validateReference('track'), throws(err.tracks)); }); } + get directory() { return this.#directory; } get tracks() { return this.#tracks; } } @@ -35,6 +47,7 @@ console.log('tracks (before):', album.tracks); try { album.update({ + directory: 'oh yes', tracks: [ 'lol', 123, diff --git a/src/thing/structures.js b/src/thing/structures.js index e1bf06c0..89c9bd39 100644 --- a/src/thing/structures.js +++ b/src/thing/structures.js @@ -1,5 +1,18 @@ // Generic structure utilities common across various Thing types. +export function validateDirectory(directory) { + if (typeof directory !== 'string') + throw new TypeError(`Expected a string, got ${directory}`); + + if (directory.length === 0) + throw new TypeError(`Expected directory to be non-zero length`); + + if (directory.match(/[^a-zA-Z0-9\-]/)) + throw new TypeError(`Expected only letters, numbers, and dash, got "${directory}"`); + + return true; +} + export function validateReference(type = '') { return ref => { if (typeof ref !== 'string') diff --git a/src/util/sugar.js b/src/util/sugar.js index 54b2df0a..38c8047f 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -104,15 +104,18 @@ export function openAggregate({ // Anything passed here should probably extend from that! May be used for // letting callers programatically distinguish between multiple aggregate // errors. - errorClass = AggregateError, + // + // This should be provided using the aggregateThrows utility function. + [openAggregate.errorClassSymbol]: errorClass = AggregateError, // Optional human-readable message to describe the aggregate error, if // constructed. message = '', - // Value to return when a provided function throws an error. (This is - // primarily useful when wrapping a function and then providing it to - // another utility, e.g. array.map().) + // Value to return when a provided function throws an error. If this is a + // function, it will be called with the arguments given to the function. + // (This is primarily useful when wrapping a function and then providing it + // to another utility, e.g. array.map().) returnOnFail = null } = {}) { const errors = []; @@ -124,7 +127,9 @@ export function openAggregate({ return fn(...args); } catch (error) { errors.push(error); - return returnOnFail; + return (typeof returnOnFail === 'function' + ? returnOnFail(...args) + : returnOnFail); } }; @@ -132,6 +137,10 @@ export function openAggregate({ return aggregate.wrap(fn)(...args); }; + aggregate.nest = (...args) => { + return aggregate.call(() => withAggregate(...args)); + }; + aggregate.map = (...args) => { const parent = aggregate; const { result, aggregate: child } = mapAggregate(...args); @@ -139,6 +148,15 @@ export function openAggregate({ return result; }; + aggregate.filter = (...args) => { + const parent = aggregate; + const { result, aggregate: child } = filterAggregate(...args); + parent.call(child.close); + return result; + }; + + aggregate.throws = aggregateThrows; + aggregate.close = () => { if (errors.length) { throw Reflect.construct(errorClass, [errors, message]); @@ -148,9 +166,19 @@ export function openAggregate({ return aggregate; } +openAggregate.errorClassSymbol = Symbol('error class'); + +// Utility function for providing {errorClass} parameter to aggregate functions. +export function aggregateThrows(errorClass) { + return {[openAggregate.errorClassSymbol]: errorClass}; +} + // Performs an ordinary array map with the given function, collating into a // results array (with errored inputs filtered out) and an error aggregate. // +// Optionally, override returnOnFail to disable filtering and map errored inputs +// to a particular output. +// // Note the aggregate property is the result of openAggregate(), still unclosed; // use aggregate.close() to throw the error. (This aggregate may be passed to a // parent aggregate: `parent.call(aggregate.close)`!) @@ -158,8 +186,8 @@ export function mapAggregate(array, fn, aggregateOpts) { const failureSymbol = Symbol(); const aggregate = openAggregate({ - ...aggregateOpts, - returnOnFail: failureSymbol + returnOnFail: failureSymbol, + ...aggregateOpts }); const result = array.map(aggregate.wrap(fn)) @@ -168,6 +196,49 @@ export function mapAggregate(array, fn, aggregateOpts) { return {result, aggregate}; } +// Performs an ordinary array filter with the given function, collating into a +// results array (with errored inputs filtered out) and an error aggregate. +// +// Optionally, override returnOnFail to disable filtering errors and map errored +// inputs to a particular output. +// +// As with mapAggregate, the returned aggregate property is not yet closed. +export function filterAggregate(array, fn, aggregateOpts) { + const failureSymbol = Symbol(); + + const aggregate = openAggregate({ + returnOnFail: failureSymbol, + ...aggregateOpts + }); + + const result = array.map(aggregate.wrap((x, ...rest) => ({ + input: x, + output: fn(x, ...rest) + }))) + .filter(value => { + // Filter out results which match the failureSymbol, i.e. errored + // inputs. + if (value === failureSymbol) return false; + + // Always keep results which match the overridden returnOnFail + // value, if provided. + if (value === aggregateOpts.returnOnFail) return true; + + // Otherwise, filter according to the returned value of the wrapped + // function. + return value.output; + }) + .map(value => { + // Then turn the results back into their corresponding input, or, if + // provided, the overridden returnOnFail value. + return (value === aggregateOpts.returnOnFail + ? value + : value.input); + }); + + return {result, aggregate}; +} + // Totally sugar function for opening an aggregate, running the provided // function with it, then closing the function and returning the result (if // there's no throw). |