Skip to content

Commit

Permalink
Extra properties in BaseStruct (#435)
Browse files Browse the repository at this point in the history
This PR expands `BaseStruct` to optionally allow undeclared properties,
which get dynamically vetted via two new methods,
`_impl_allowExtraProperty()` and `_impl_extraProperty()`.
  • Loading branch information
danfuzz authored Nov 18, 2024
2 parents 1e960b8 + 99dc74d commit fe8b124
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 6 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Other notable changes:
* `structy`:
* Started allowing any object (plain or not) to be used as the argument to the
`BaseStruct` constructor.
* Added the option to allow undeclared properties to be allowed and
dynamically vetted, via two additional `_impl*` methods.
* `webapp-builtins`:
* Simplified naming scheme for preserved log files: Names now always include
a `-<num>` suffix after the date.
Expand Down
56 changes: 51 additions & 5 deletions src/structy/export/BaseStruct.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,40 @@ export class BaseStruct {
Object.freeze(this);
}

/**
* Checks to see if a property name is allowed. This is called by the base
* class for any encountered property which doesn't have a corresponding
* `_prop_*()` method. If it returns `true`, then the property-value pair is
* passed to {@link #_impl_extraProperty} for further processing. The default
* (base class) implementation always returns `false`.
*
* @param {string} name Property name.
* @returns {boolean} `true` if `name` is allowed on the instance, or `false`
* if not.
*/
_impl_allowExtraProperty(name) { // eslint-disable-line no-unused-vars
return false;
}

/**
* Like a `_prop_*()` (etc.) method, but called for any property whose name
* does not have a corresponding `_prop_*()` method and for which
* {@link #allowExtraProperty} returned `true`. It gets passed both the
* property name _and_ the value. The default (base class) implementation
* always returns the given value.
*
* @param {string} name Property name.
* @param {*} value Proposed property value.
* @returns {*} Accepted property value.
*/
_impl_extraProperty(name, value) { // eslint-disable-line no-unused-vars
return value;
}

/**
* Gets the prefix used on instance members of the class which are to be
* treated as property-checker methods. This is `struct` by default.
* Subclasses should override this as appropriate for their context.
* treated as property-checker methods. This is `prop` by default. Subclasses
* should override this as appropriate for their context.
*
* @returns {string} The property-checker prefix, as a plain word (not
* surrounded by underscores).
Expand Down Expand Up @@ -114,9 +144,25 @@ export class BaseStruct {
}

if (leftovers.size !== 0) {
const names = [...leftovers].join(', ');
const word = (leftovers.size === 1) ? 'property' : 'properties';
throw new Error(`Extra ${word}: \`${names}\``);
const disallowed = [];

for (const name of leftovers) {
if (this._impl_allowExtraProperty(name)) {
const value = this._impl_extraProperty(name, rawObject[name]);
if (value === undefined) {
throw new Error(`Extra property checker \`_impl_extraProperty()\` did not return a value. Maybe missing a \`return\`?`);
}
props[name] = value;
} else {
disallowed.push(name);
}
}

if (disallowed.length !== 0) {
const names = disallowed.join(', ');
const word = (disallowed.length === 1) ? 'property' : 'properties';
throw new Error(`Extra ${word}: \`${names}\``);
}
}

const finalProps = this._impl_validate(props);
Expand Down
49 changes: 48 additions & 1 deletion src/structy/tests/BaseStruct.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ describe('using a subclass with one defaultable property and one required proper
});

test('throws if a property-specific validation returns `undefined`', () => {
expect(() => SomeStruct.eval({ florp: 'return-undefined' })).toThrow();
expect(() => SomeStruct.eval({ florp: 'return-undefined' })).toThrow(/did not return/);
});

test('throws if `_impl_validate()` returns `undefined`', () => {
Expand Down Expand Up @@ -325,3 +325,50 @@ describe('using a subclass with one defaultable property and one required proper
});
});
});

describe('using a subclass which allows extra properties', () => {
class SomeStruct extends BaseStruct {
// @defaultConstructor

_impl_allowExtraProperty(name) {
return name.startsWith('yes');
}

_impl_extraProperty(name, value) {
switch (name) {
case 'yesUndefined': return undefined;
case 'yesYes': return `${value}-${value}`;
default: return value;
}
}
}

describe('constructor()', () => {
test('accepts an allowed extra property', () => {
const arg = { yes: 987 };
const got = new SomeStruct(arg);
expect(got.yes).toBe(987);
});

test('processes an allowed extra property via `_impl_extraProperty()`', () => {
const arg = { yesYes: 'super' };
const got = new SomeStruct(arg);
expect(got.yesYes).toBe('super-super');
});

test('throws given a disallowed extra property', () => {
const arg = { nope: 123 };
expect(() => new SomeStruct(arg)).toThrow(/Extra.*property.*nope/);
});

test('throws given two disallowed extra properties', () => {
const arg = { no: 123, way: 234 };
expect(() => new SomeStruct(arg)).toThrow(/Extra.*properties.*no, way/);
});

test('throws given an extra property whose call to `_impl_extraProperty()` fails to return a value', () => {
const arg = { yesUndefined: 123 };
expect(() => new SomeStruct(arg)).toThrow(/did not return/);
});
});
});

0 comments on commit fe8b124

Please sign in to comment.