From: Lady Date: Wed, 14 Jun 2023 02:48:09 +0000 (-0700) Subject: Use configurable metadata for model X-Git-Url: https://git.ladys.computer/Etiquette/commitdiff_plain/e20983aca1b36dd368d52e02ee018c2eaf0ca55f?ds=sidebyside Use configurable metadata for model Instead of hardcoding properties and methods, use a schema to generate them. This makes the code significantly dry·er at the cost of some slightly obtuse metaprogramming. The schema format is somewhat minimal and makes some assumptions; this is not intended as a generalist O·W·L processor or anything of the sort. Previously, `TagSystem::Tag` returned a bound class constructor. With this commit, it instead returns a (manually‐defined) constructor function which effectively does the partial application in its function body. Each constructor has its own unique prototype object (inheriting from `Tag.prototype`) and its own static methods (bound forms of `Tag` static methods). Constructors themselves inherit from `Function.prototype` (for now). --- diff --git a/model.js b/model.js index ba71361..3f0b6ad 100644 --- a/model.js +++ b/model.js @@ -9,118 +9,7 @@ import { Storage } from "./memory.js"; import { taggingDiscoveryContext } from "./names.js"; - -// TODO: Move these somewhere else and allow for modification before -// they are used, freezing them only once tags actually start being -// constructed (probably on first call to the `TagSystem` initializer -// for convenience). -// -// Or else make them properties of the tag system itself and ∼fully -// modifiable. - -/** - * Tag kinds which denote entity tags. - * - * ※ This object is not exposed. - */ -const ENTITY_TAG_KINDS = new Set([ - "EntityTag", - "CharacterTag", - "InanimateEntityTag", -]); - -/** - * Tag kinds which denote relationship tags. - * - * ※ This object is not exposed. - */ -const RELATIONSHIP_TAG_KINDS = new Set([ - "RelationshipTag", - "FamilialRelationship Tag", - "FriendshipTag", - "RivalryTag", - "RomanticRelationshipTag", - "SexualRelationshipTag", -]); - -/** - * Tag kinds which denote setting tags. - * - * ※ This object is not exposed. - */ -const SETTING_TAG_KINDS = new Set([ - "SettingTag", - "LocationTag", - "TimePeriodTag", - "UniverseTag", -]); - -/** - * Tag kinds which denote conceptual tags. - * - * ※ This object is not exposed. - */ -const CONCEPTUAL_TAG_KINDS = new Set(function* () { - yield "ConceptualTag"; - yield* RELATIONSHIP_TAG_KINDS; -}()); - -/** - * All recognized tag kinds. - * - * ※ This object is not exposed. - */ -const TAG_KINDS = new Set(function* () { - yield "Tag"; - yield "CanonTag"; - yield* CONCEPTUAL_TAG_KINDS; - yield* ENTITY_TAG_KINDS; - yield "GenreTag"; - yield* SETTING_TAG_KINDS; -}()); - -/** - * Tag kinds which can be in canon. - * - * ※ This object is not exposed. - */ -const HAS_IN_CANON = new Set(function* () { - yield* ENTITY_TAG_KINDS; - yield* SETTING_TAG_KINDS; -}()); - -/** - * Tag kinds which can be involved in relationship tags. - * - * ※ This object is not exposed. - */ -const INVOLVABLE_IN_RELATIONSHIP = new Set(function* () { - yield "CharacterTag"; - yield* RELATIONSHIP_TAG_KINDS; -}()); - -/** - * Properties which take literal values instead of identifiers. - * - * These are the label terms. - */ -const LITERAL_TERMS = new Set([ - "prefLabel", - "altLabel", - "hiddenLabel", -]); - -/** - * Properties to skip when diffing. - * - * These are all inverses of properties included in diffs and cannot be - * changed manually. - */ -const SKIP_IN_DIFF = new Set([ - "hasInCanon", - "isIncludedIn", - "narrower", -]); +import schema from "./schema.js"; /** * A tag. @@ -143,6 +32,9 @@ class Tag { /** The `Storage` managed by this `Tag`’s `TagSystem`. */ #storage; + /** The schema in use for this `Tag`. */ + #schema; + /** * The 30‐bit W·R·M·G base32 identifier with leading checksum which * has been assigned to this `Tag`. @@ -166,6 +58,110 @@ class Tag { /** The current (modified) data associated with this `Tag`. */ #data = tagData(); + /** + * Adds the provided label(s) to this `Tag` as the provided + * predicate, then returns this `Tag`. + */ + #addLabel(predicate, ...labels) { + const values = this.#data[predicate]; + for (const $ of labels) { + // Iterate over each provided label and attempt to add it. + const literal = langString($); + values.add(literal); + } + return this; + } + + /** + * Adds the provided tags to the list of tags that this `Tag` is + * related to by the provided predicate, then returns this `Tag`. + * + * Arguments may be string identifiers or objects with an + * `.identifier` property. + */ + #addTag(predicate, ...tags) { + const storage = this.#storage; + const values = this.#data[predicate]; + for (const $ of tags) { + // Iterate over each tag and attempt to state the predicate. + const identifier = toIdentifier($); + if (identifier == null) { + // ☡ The current tag has no identifier. + throw new TypeError( + `Cannot state ${predicate} of Tag: Identifier must not be nullish.`, + ); + } else if (values.has(identifier)) { + // Short‐circuit: The identifier has already been stated with + // this predicate. + /* do nothing */ + } else { + // The current tag has an identifier, but it hasn’t been stated + // with this predicate yet. + const tag = storage.get(identifier); + if (tag == null) { + // ☡ The current tag has not been persisted to this `Tag`’s + // storage. + throw new RangeError( + `Cannot state ${predicate} of Tag: Identifier is not persisted: ${identifier}.`, + ); + } else if (!this.#isTagInStorage(tag)) { + // ☡ The current tag is not a tag in the correct tag system. + throw new TypeError( + `Cannot state ${predicate} of Tag: Tags must be from the same Tag System, but got: ${identifier}.`, + ); + } else if ( + !isObjectPredicateOK( + this.#schema, + this.#kind, + predicate, + tag.#kind, + ) + ) { + // ☡ This tag and the current tag form an invalid pair for + // this predicate. + throw new TypeError( + `Cannot state ${predicate} of Tag: Not valid for domain and range: ${this.#kind}, ${tag.#kind}.`, + ); + } else { + // The current tag is a tag in the correct tag system; add + // its identifier. + values.add(identifier); + } + } + } + return this; + } + + /** + * Removes the provided string label(s) from this `Tag` as the + * provided predicate, then returns this `Tag`. + */ + #deleteLabel(predicate, ...labels) { + const values = this.#data[predicate]; + for (const $ of labels) { + // Iterate over each provided label and attempt to remove it. + const literal = langString($); + values.delete(literal); + } + return this; + } + + /** + * Removes the provided tags from the list of tags that this `Tag` is + * related to by the provided predicate, then returns this `Tag`. + * + * Arguments may be string identifiers or objects with an + * `.identifier` property. + */ + #deleteTag(predicate, ...tags) { + const values = this.#data[predicate]; + for (const $ of tags) { + // Iterate over the provided tags and delete them. + values.delete(toIdentifier($)); + } + return this; + } + /** * Returns whether or not the provided value is a tag which shares a * storage with this tag. @@ -183,6 +179,94 @@ class Tag { } } + /** + * Yields the labels of this `Tag` according to the provided + * predicate. + */ + *#yieldLabels(predicate) { + yield* this.#data[predicate]; + } + + /** + * Yields the tags that this `Tag` is related to by the provided + * predicate. + */ + *#yieldTags(predicate) { + const storage = this.#storage; + for (const identifier of this.#data[predicate]) { + // Iterate over the tags in this predicate and yield them if + // possible. + const tag = storage.get(identifier); + if ( + !this.#isTagInStorage(tag) || !isObjectPredicateOK( + this.#schema, + this.#kind, + predicate, + tag.#kind, + ) + ) { + // The tag no longer appears in storage or is not compatible; + // perhaps it was deleted. + /* do nothing */ + } else { + // The tag exists and is constructable from storage. + yield tag; + } + } + } + + /** + * Yields the tags that this `Tag` is related to by the provided + * predicate, figured transitively. + */ + *#yieldTransitiveTags(transitivePredicate, basePredicate) { + const storage = this.#storage; + const encountered = new Set(); + let pending = new Set(this.#data[basePredicate]); + while (pending.size > 0) { + // Loop until all tags of the predicate have been encountered. + const processing = pending; + pending = new Set(); + for (const identifier of processing) { + // Iterate over the tags and yield them if possible. + if (!encountered.has(identifier)) { + // The tag has not been encountered before. + encountered.add(identifier); + const tag = storage.get(identifier); + if ( + !this.#isTagInStorage(tag) || !isObjectPredicateOK( + this.#schema, + this.#kind, + transitivePredicate, + tag.#kind, + ) + ) { + // The tag no longer appears in storage or is not + // compatible; perhaps it was deleted. + /* do nothing */ + } else { + // The tag exists and is constructable from storage. + yield tag; + for (const transitive of tag.#data[basePredicate]) { + // Iterate over the nested tags of the current tag and + // add them to pending as needed. + if (!encountered.has(transitive)) { + // The nested tag has not been encountered yet. + pending.add(transitive); + } else { + // The nested tag has already been encountered. + /* do nothing */ + } + } + } + } else { + // The tag has already been encountered. + /* do nothing */ + } + } + } + } + /** * Constructs a new `Tag` of the provided kind and with the provided * preferred label. @@ -194,22 +278,147 @@ class Tag { * * ☡ This constructor throws if the provided kind is not supported. */ - constructor(system, storage, kind = "Tag", prefLabel = "") { + constructor(system, storage, schema, kind = "Tag", prefLabel = "") { + this.#system = system; + this.#storage = storage; + this.#schema = schema; const kindString = `${kind}`; - if (TAG_KINDS.has(kindString)) { - // The provided kind is one of the recognized tag kinds. - this.#system = system; - this.#storage = storage; - this.#kind = kindString; - this.#data.prefLabel = prefLabel; - } else { + if (!(kindString in schema.classes)) { // The provided kind is not supported. throw new RangeError( `Cannot construct Tag: Unrecognized kind: ${kind}.`, ); + } else { + // The provided kind is one of the recognized tag kinds. + this.#kind = kindString; + this.#data.prefLabel = prefLabel; } } + /** + * Returns a new `Tag` constructor for the provided system, storage, + * schema, created with an appropriate prototype for the properties + * so defined. + * + * ※ This function is not exposed. + */ + static For(system, storage, schema) { + const { + objectProperties, + transitiveProperties, + dataProperties, + } = schema; + const constructor = function (...$s) { + return Reflect.construct( + Tag, + [system, storage, schema, ...$s], + new.target, + ); + }; + Object.defineProperties(constructor, { + prototype: { + configurable: false, + enumerable: false, + value: Object.create( + Tag.prototype, + Object.fromEntries(Array.from( + function* () { + for (const key in objectProperties) { + const { + inverseOf, + subPropertyOf, + } = objectProperties[key]; + if (key in transitiveProperties) { + // Transitive property methods are added by their + // nontransitive subproperties. + /* do nothing */ + } else { + yield [`${key}Tags`, function* () { + yield* this.#yieldTags(key); + }]; + if (inverseOf == null) { + const cased = key[0].toUpperCase() + + key.substring(1); + yield [`add${cased}Tag`, function (...tags) { + return this.#addTag(key, ...tags); + }]; + yield [`delete${cased}Tag`, function (...tags) { + return this.#deleteTag(key, ...tags); + }]; + } else { + /* do nothing */ + } + if ( + subPropertyOf != null && + subPropertyOf in transitiveProperties + ) { + yield [`${subPropertyOf}Tags`, function* () { + yield* this.#yieldTransitiveTags( + subPropertyOf, + key, + ); + }]; + } else { + /* do nothing */ + } + } + } + for (const key in dataProperties) { + if (key != "prefLabel") { + const cased = key[0].toUpperCase() + + key.substring(1); + yield [`${key}s`, function* () { + yield* this.#yieldLabels(key); + }]; + yield [`add${cased}`, function (...labels) { + return this.#addLabel(key, ...labels); + }]; + yield [`delete${cased}`, function (...labels) { + return this.#deleteLabel(key, ...labels); + }]; + } else { + /* do nothing */ + } + } + }(), + ([key, value]) => [key, { + configurable: true, + enumerable: false, + value: Object.defineProperty(value, "name", { + value: key, + }), + writable: true, + }], + )), + ), + writable: false, + }, + }); + return Object.defineProperties( + constructor, + Object.fromEntries([ + ["name", { value: "TagSystem::Tag" }], + ...[ + "all", + "fromIRI", + "fromIdentifier", + "fromTagURI", + "identifiers", + Storage.toInstance, + ].map((key) => [key, { + configurable: true, + enumerable: false, + value: Object.defineProperty( + Tag[key].bind(constructor, system, storage), + "name", + { value: String(key) }, + ), + writable: true, + }]), + ]), + ); + } + /** * Yields the tags in the `TagSystem` associated with this * constructor. @@ -331,569 +540,67 @@ class Tag { } /** - * Yields the tag identifiers in the `TagSystem` associated with this - * constructor. - * - * ※ The first two arguments of this function are bound when - * generating the value of `TagSystem::Tag`. It isn’t possible to - * access this function in its unbound form from outside this module. - */ - static *identifiers(system, storage) { - for (const [identifier, instance] of storage.entries()) { - // Iterate over the entries and yield the ones which are `Tag`s - // in this `TagSystem`. - if (Tag.getSystem(instance) == system) { - // The current instance is a `Tag` in this `TagSystem`. - yield identifier; - } else { - // The current instance is not a `Tag` in this `TagSystem`. - /* do nothing */ - } - } - } - - /** - * Returns a new `Tag` constructed from the provided data and with - * the provided identifier. - * - * ※ This function will not work if called directly from `Tag` (and - * nor is it available *to* be called as such from outside this - * module). It must be called from a `TagSystem::Tag` bound - * constructor. - * - * ※ This function is not really intended for public usage. - */ - static [Storage.toInstance](_system, _storage, data, identifier) { - const tag = new this(data.kind); - tag.#identifier = `${identifier}`; - tag.#persistedData = tagData(data); - tag.#data = tagData(data); - return tag; - } - - static { - // Overwrite the default `::constructor` method to instead give the - // actual (bound) constructor which was used to generate a given - // `Tag`. - Object.defineProperties(this.prototype, { - constructor: { - configurable: true, - enumerable: false, - get() { - // All `Tag`s are constructed via the `.Tag` constructor - // available in their `TagSystem`; return it. - return this.#system.Tag; - }, - set: undefined, - }, - }); - } - - /** - * Adds the provided label(s) to this `Tag` as alternate labels, then - * returns this `Tag`. - */ - addAltLabel(...labels) { - const altLabels = this.#data.altLabel; - let objectLabels = null; // initialized on first use - for (const $ of labels) { - // Iterate over each provided label and attempt to add it. - const literal = langString($); - if (Object(literal) === literal) { - // The current label is a language‐tagged string. - objectLabels ??= [...function* () { - for (const altLabel of altLabels) { - // Iterate over the existing labels and yield the - // language‐tagged strings. - if (Object(altLabel) === altLabel) { - // The current existing label is a language‐tagged - // string. - yield altLabel; - } else { - // The current existing label is not a language‐tagged - // string. - /* do nothing */ - } - } - }()]; - if ( - objectLabels.some((objectLabel) => - objectLabel["@value"] == literal["@value"] && - objectLabel["@language"] == literal["@language"] - ) - ) { - // There is a match with the current label in the existing - // labels. - /* do nothing */ - } else { - // There is no match and this label must be added. - altLabels.add(literal); - objectLabels.push(literal); - } - } else { - // The current label is a simple string. - altLabels.add(literal); - } - } - return this; - } - - /** - * Adds the provided tags to the list of tags that this `Tag` is - * narrower than, then returns this `Tag`. - * - * Arguments may be string identifiers or objects with an - * `.identifier` property. - */ - addBroaderTag(...tags) { - const storage = this.#storage; - const broader = this.#data.broader; - for (const $ of tags) { - // Iterate over each tag and attempt to set it as broader than - // this `Tag`. - const identifier = toIdentifier($); - if (identifier == null) { - // ☡ The current tag has no identifier. - throw new TypeError( - "Cannot assign broader to Tag: Identifier must not be nullish.", - ); - } else if (broader.has(identifier)) { - // Short‐circuit: The identifier is already something this - // `Tag` is narrower than. - /* do nothing */ - } else { - // The current tag has an identifier. - const tag = storage.get(identifier); - if (tag == null) { - // ☡ The current tag has not been persisted to this `Tag`’s - // storage. - throw new RangeError( - `Cannot assign broader to Tag: Identifier is not persisted: ${identifier}.`, - ); - } else if (!this.#isTagInStorage(tag)) { - // ☡ The current tag is not a tag in the correct tag system. - throw new TypeError( - `Cannot assign broader to Tag: Tags must be from the same Tag System, but got: ${identifier}.`, - ); - } else { - // The current tag is a tag in the correct tag system; add - // its identifier. - broader.add(identifier); - } - } - } - return this; - } - - /** - * Adds the provided label(s) to this `Tag` as hidden labels, then - * returns this `Tag`. - */ - addHiddenLabel(...labels) { - const hiddenLabels = this.#data.hiddenLabel; - let objectLabels = null; // initialized on first use - for (const $ of labels) { - // Iterate over each provided label and attempt to add it. - const literal = langString($); - if (Object(literal) === literal) { - // The current label is a language‐tagged string. - objectLabels ??= [...function* () { - for (const hiddenLabel of hiddenLabels) { - // Iterate over the existing labels and yield the - // language‐tagged strings. - if (Object(hiddenLabel) === hiddenLabel) { - // The current existing label is a language‐tagged - // string. - yield hiddenLabel; - } else { - // The current existing label is not a language‐tagged - // string. - /* do nothing */ - } - } - }()]; - if ( - objectLabels.some((objectLabel) => - objectLabel["@value"] == literal["@value"] && - objectLabel["@language"] == literal["@language"] - ) - ) { - // There is a match with the current label in the existing - // labels. - /* do nothing */ - } else { - // There is no match and this label must be added. - hiddenLabels.add(literal); - objectLabels.push(literal); - } - } else { - // The current label is a simple string. - hiddenLabels.add(literal); - } - } - return this; - } - - /** - * Adds the provided tags to the list of tags that this `Tag` is in - * canon with, then returns this `Tag`. - * - * Arguments may be string identifiers or objects with an - * `.identifier` property. - * - * ☡ This method will throw if a provided argument does not indicate - * a canon tag, or if this `Tag` is not of a kind which can be placed - * in canon. - */ - addInCanonTag(...tags) { - const storage = this.#storage; - const kind = this.#kind; - const inCanon = this.#data.inCanon; - if (!HAS_IN_CANON.has(kind)) { - // ☡ This is not an entity tag, setting tag, or recognized - // subclass. - throw new TypeError( - `Cannot put Tag in canon: Incorrect Tag type: ${kind}.`, - ); - } else { - // This has a kind which can be placed in canon. - for (const $ of tags) { - // Iterate over each tag and attempt to set this `Tag` in canon - // of it. - const identifier = toIdentifier($); - if (identifier == null) { - // ☡ The current tag has no identifier. - throw new TypeError( - "Cannot put Tag in canon: Identifier must not be nullish.", - ); - } else if (inCanon.has(identifier)) { - // Short‐circuit: The identifier is already something this - // `Tag` is in canon of. - /* do nothing */ - } else { - // The current tag has an identifier. - const tag = storage.get(identifier); - if (tag == null) { - // ☡ The current tag has not been persisted to this `Tag`’s - // storage. - throw new RangeError( - `Cannot put Tag in canon: Identifier is not persisted: ${identifier}.`, - ); - } else if ( - // ※ If the first check succeeds, then the current tag - // must have `Tag` private class features. - !this.#isTagInStorage(tag) || tag.#kind != "CanonTag" - ) { - // ☡ The current tag is not a canon tag in the correct - // tag system. - throw new TypeError( - `Cannot put Tag in canon: Tags can only be in Canon Tags from the same Tag System, but got: ${identifier}.`, - ); - } else { - // The current tag is a canon tag in the correct tag - // system; add its identifier. - inCanon.add(identifier); - } - } - } - } - return this; - } - - /** - * Adds the provided tags to the list of tags that this `Tag` - * involves, then returns this `Tag`. - * - * Arguments may be string identifiers or objects with an - * `.identifier` property. - * - * ☡ This method will throw if this `Tag` is not a conceptual tag, or - * if this `Tag` is a relationship tag and a provided argument does - * not indicate a character or relationship tag. - */ - addInvolvesTag(...tags) { - const storage = this.#storage; - const kind = this.#kind; - const involves = this.#data.involves; - if (!CONCEPTUAL_TAG_KINDS.has(kind)) { - // ☡ This is not a conceptual tag or recognized subclass. - throw new TypeError( - `Cannot involve Tag: Incorrect Tag type: ${kind}.`, - ); - } else { - // This is a conceptual tag. - for (const $ of tags) { - // Iterate over each tag and attempt to set this `Tag` as - // involving it. - const identifier = toIdentifier($); - if (identifier == null) { - // ☡ The current tag has no identifier. - throw new TypeError( - "Cannot involve Tag: Identifier must not be nullish.", - ); - } else if (involves.has(identifier)) { - // Short‐circuit: The identifier is already something this - // `Tag` involves. - /* do nothing */ - } else { - // The current tag has an identifier. - const tag = storage.get(identifier); - if (tag == null) { - // ☡ The current tag has not been persisted to this `Tag`’s - // storage. - throw new RangeError( - `Cannot involve Tag: Identifier is not persisted: ${identifier}.`, - ); - } else if ( - // ※ If the first check succeeds, then the current tag - // must have `Tag` private class features. - !this.#isTagInStorage(tag) || - RELATIONSHIP_TAG_KINDS.has(kind) && - !INVOLVABLE_IN_RELATIONSHIP.has(tag.#kind) - ) { - // ☡ The current tag is in the correct tag system and - // includable. - throw new TypeError( - `Cannot involve Tag: Tags must be the same Tag System and involvable, but got: ${identifier}.`, - ); - } else { - // The current tag is an involvable tag in the correct tag - // system; add its identifier. - involves.add(identifier); - } - } - } - } - return this; - } - - /** Yields the alternative labels of this `Tag`. */ - *altLabels() { - yield* this.#data.altLabel; - } - - /** Returns the authority (domain) name for this `Tag`. */ - get authorityName() { - return this.#system.authorityName; - } - - /** Yields `Tag`s which are broader than this `Tag`. */ - *broaderTags() { - const storage = this.#storage; - for (const identifier of this.#data.broader) { - // Iterate over the broader tags and yield them if possible. - const tag = storage.get(identifier); - if (!this.#isTagInStorage(tag)) { - // The broader tag no longer appears in storage; perhaps it was - // deleted. - /* do nothing */ - } else { - // The broader tag exists and is constructable from storage. - yield tag; - } - } - } - - /** Yields `Tag`s which are broader than this `Tag`, transitively. */ - *broaderTransitiveTags() { - const storage = this.#storage; - const encountered = new Set(); - let pending = new Set(this.#data.broader); - while (pending.size > 0) { - // Loop until all broader tags have been encountered. - const processing = pending; - pending = new Set(); - for (const identifier of processing) { - // Iterate over the broader tags and yield them if possible. - if (!encountered.has(identifier)) { - // The broader tag has not been encountered before. - encountered.add(identifier); - const tag = storage.get(identifier); - if (!this.#isTagInStorage(tag)) { - // The broader tag no longer appears in storage; perhaps it - // was deleted. - /* do nothing */ - } else { - // The broader tag exists and is constructable from - // storage. - yield tag; - for (const transitive of tag.#data.broader) { - // Iterate over the broader tags of the current broader - // tag and add them to pending as needed. - if (!encountered.has(transitive)) { - // The broader broader tag has not been encountered - // yet. - pending.add(transitive); - } else { - // The broader broader tag has already been - // encountered. - /* do nothing */ - } - } - } - } else { - // The broader tag has already been encountered. - /* do nothing */ - } - } - } - } - - /** - * Removes the provided string label(s) from this `Tag` as alternate - * labels, then returns this `Tag`. - */ - deleteAltLabel(...labels) { - const altLabels = this.#data.altLabel; - let objectLabels = null; // initialized on first use - for (const $ of labels) { - // Iterate over each provided label and attempt to remove it. - const literal = langString($); - if (Object(literal) === literal) { - // The current label is a language‐tagged string. - objectLabels ??= [...function* () { - for (const altLabel of altLabels) { - // Iterate over the existing labels and yield the - // language‐tagged strings. - if (Object(altLabel) === altLabel) { - // The current existing label is a language‐tagged - // string. - yield altLabel; - } else { - // The current existing label is not a language‐tagged - // string. - /* do nothing */ - } - } - }()]; - const existing = objectLabels.find((objectLabel) => - objectLabel["@value"] == literal["@value"] && - objectLabel["@language"] == literal["@language"] - ); - altLabels.delete(existing); - } else { - // The current label is a simple string. - altLabels.delete(literal); - } - } - return this; - } - - /** - * Removes the provided tags from the list of tags that this `Tag` is - * narrower than, then returns this `Tag`. - * - * Arguments may be string identifiers or objects with an - * `.identifier` property. - */ - deleteBroaderTag(...tags) { - const broader = this.#data.broader; - for (const $ of tags) { - // Iterate over the provided tags and delete them. - broader.delete(toIdentifier($)); - } - return this; - } - - /** - * Removes the provided string label(s) from this `Tag` as hidden - * labels, then returns this `Tag`. + * Yields the tag identifiers in the `TagSystem` associated with this + * constructor. + * + * ※ The first two arguments of this function are bound when + * generating the value of `TagSystem::Tag`. It isn’t possible to + * access this function in its unbound form from outside this module. */ - deleteHiddenLabel(...labels) { - const hiddenLabels = this.#data.hiddenLabel; - let objectLabels = null; // initialized on first use - for (const $ of labels) { - // Iterate over each provided label and attempt to remove it. - const literal = langString($); - if (Object(literal) === literal) { - // The current label is a language‐tagged string. - objectLabels ??= [...function* () { - for (const hiddenLabel of hiddenLabels) { - // Iterate over the existing labels and yield the - // language‐tagged strings. - if (Object(hiddenLabel) === hiddenLabel) { - // The current existing label is a language‐tagged - // string. - yield hiddenLabel; - } else { - // The current existing label is not a language‐tagged - // string. - /* do nothing */ - } - } - }()]; - const existing = objectLabels.find((objectLabel) => - objectLabel["@value"] == literal["@value"] && - objectLabel["@language"] == literal["@language"] - ); - hiddenLabels.delete(existing); + static *identifiers(system, storage) { + for (const [identifier, instance] of storage.entries()) { + // Iterate over the entries and yield the ones which are `Tag`s + // in this `TagSystem`. + if (Tag.getSystem(instance) == system) { + // The current instance is a `Tag` in this `TagSystem`. + yield identifier; } else { - // The current label is a simple string. - hiddenLabels.delete(literal); + // The current instance is not a `Tag` in this `TagSystem`. + /* do nothing */ } } - return this; } /** - * Removes the provided tags from the list of tags that this `Tag` is - * in canon with, then returns this `Tag`. + * Returns a new `Tag` constructed from the provided data and with + * the provided identifier. * - * Arguments may be string identifiers or objects with an - * `.identifier` property. - */ - deleteInCanonTag(...tags) { - const inCanon = this.#data.inCanon; - for (const $ of tags) { - // Iterate over the provided tags and delete them. - inCanon.delete(toIdentifier($)); - } - return this; - } - - /** - * Removes the provided tags from the list of tags that this `Tag` - * involves, then returns this `Tag`. + * ※ This function will not work if called directly from `Tag` (and + * nor is it available *to* be called as such from outside this + * module). It must be called from a `TagSystem::Tag` bound + * constructor. * - * Arguments may be string identifiers or objects with an - * `.identifier` property. + * ※ This function is not really intended for public usage. */ - deleteInvolvesTag(...tags) { - const involves = this.#data.involves; - for (const $ of tags) { - // Iterate over the provided tags and delete them. - involves.delete(toIdentifier($)); - } - return this; + static [Storage.toInstance](_system, _storage, data, identifier) { + const tag = new this(data.kind); + tag.#identifier = `${identifier}`; + tag.#persistedData = tagData(data); + tag.#data = tagData(data); + return tag; } - /** Yields `Tag`s that are in canon of this `Tag`. */ - *hasInCanonTags() { - const storage = this.#storage; - if (this.#kind == "CanonTag") { - // This is a canon tag. - for (const identifier of this.#data.hasInCanon) { - // Iterate over the tags in canon and yield them if possible. - const tag = storage.get(identifier); - if ( - !this.#isTagInStorage(tag) || !HAS_IN_CANON.has(tag.#kind) - ) { - // The tag in canon no longer appears in storage; perhaps it - // was deleted. - /* do nothing */ - } else { - // The tag in canon exists and is constructable from storage. - yield tag; - } - } - } else { - /* do nothing */ - } + static { + // Overwrite the default `::constructor` method to instead give the + // actual (bound) constructor which was used to generate a given + // `Tag`. + Object.defineProperties(this.prototype, { + constructor: { + configurable: true, + enumerable: false, + get() { + // All `Tag`s are constructed via the `.Tag` constructor + // available in their `TagSystem`; return it. + return this.#system.Tag; + }, + set: undefined, + }, + }); } - /** Yields the hidden labels of this `Tag`. */ - *hiddenLabels() { - yield* this.#data.hiddenLabel; + /** Returns the authority (domain) name for this `Tag`. */ + get authorityName() { + return this.#system.authorityName; } /** Returns the identifier of this `Tag`. */ @@ -901,80 +608,6 @@ class Tag { return this.#identifier; } - /** Yields `Tag`s that this `Tag` is in canon of. */ - *inCanonTags() { - const storage = this.#storage; - if (HAS_IN_CANON.has(this.#kind)) { - // This tag can be placed in canon. - for (const identifier of this.#data.inCanon) { - // Iterate over the canon tags and yield them if possible. - const tag = storage.get(identifier); - if (!this.#isTagInStorage(tag) || tag.#kind != "CanonTag") { - // The canon tag no longer appears in storage; perhaps it was - // deleted. - /* do nothing */ - } else { - // The canon tag exists and is constructable from storage. - yield tag; - } - } - } else { - // This tag cannot be placed in canon. - /* do nothing */ - } - } - - /** Yields `Tag`s which involve this `Tag`. */ - *involvedInTags() { - const storage = this.#storage; - for (const identifier of this.#data.involvedIn) { - // Iterate over the involving tags and yield them if possible. - const tag = storage.get(identifier); - const tagKind = tag.#kind; - if ( - !this.#isTagInStorage(tag) || - !CONCEPTUAL_TAG_KINDS.has(tagKind) || - RELATIONSHIP_TAG_KINDS.has(tagKind) && - !INVOLVABLE_IN_RELATIONSHIP.has(this.#kind) - ) { - // The including tag no longer appears in storage; perhaps it - // was deleted. - /* do nothing */ - } else { - // The including tag exists and is constructable from storage. - yield tag; - } - } - } - - /** Yields `Tag`s that this `Tag` involves. */ - *involvesTags() { - const storage = this.#storage; - const kind = this.#kind; - if (CONCEPTUAL_TAG_KINDS.has(kind)) { - // This tag can involve other tags. - for (const identifier of this.#data.involves) { - // Iterate over the involved and yield them if possible. - const tag = storage.get(identifier); - if ( - !this.#isTagInStorage(tag) || - RELATIONSHIP_TAG_KINDS.has(kind) && - !INVOLVABLE_IN_RELATIONSHIP.has(tag.#kind) - ) { - // The involved tag no longer appears in storage; perhaps it - // was deleted. - /* do nothing */ - } else { - // The involved tag exists and is constructable from storage. - yield tag; - } - } - } else { - // This tag cannot involve other tags. - /* do nothing */ - } - } - /** Returns the I·R·I for this `Tag`. */ get iri() { const { identifier, iriSpace } = this; @@ -991,69 +624,6 @@ class Tag { return this.#kind; } - /** Yields `Tag`s which are narrower than this `Tag`. */ - *narrowerTags() { - const storage = this.#storage; - for (const identifier of this.#data.narrower) { - const tag = storage.get(identifier); - if (!this.#isTagInStorage(tag)) { - // The narrower tag no longer appears in storage; perhaps it - // was deleted. - /* do nothing */ - } else { - // The narrower tag exists and is constructable from storage. - yield tag; - } - } - } - - /** - * Yields `Tag`s which are narrower than this `Tag`, transitively. - */ - *narrowerTransitiveTags() { - const storage = this.#storage; - const encountered = new Set(); - let pending = new Set(this.#data.narrower); - while (pending.size > 0) { - // Loop until all narrower tags have been encountered. - const processing = pending; - pending = new Set(); - for (const identifier of processing) { - // Iterate over the narrower tags and yield them if possible. - if (!encountered.has(identifier)) { - // The narrower tag has not been encountered before. - encountered.add(identifier); - const tag = storage.get(identifier); - if (!this.#isTagInStorage(tag)) { - // The narrower tag no longer appears in storage; perhaps - // it was deleted. - /* do nothing */ - } else { - // The narrower tag exists and is constructable from - // storage. - yield tag; - for (const transitive of tag.#data.narrower) { - // Iterate over the narrower tags of the current narrower - // tag and add them to pending as needed. - if (!encountered.has(transitive)) { - // The narrower narrower tag has not been encountered - // yet. - pending.add(transitive); - } else { - // The narrower narrower tag has already been - // encountered. - /* do nothing */ - } - } - } - } else { - // The narrower tag has already been encountered. - /* do nothing */ - } - } - } - } - /** * Persist this `Tag` to storage and return an ActivityStreams * serialization of a Tag Activity representing any changes, or @@ -1067,19 +637,27 @@ class Tag { * as broader than another causes the other tag to reciprocally be * marked as narrower. * - * ※ The inverse terms `hasInCanon`, `isIncludedIn`, and `narrower` - * will never appear in the predicates of generated activities. + * ※ Inverse object properties will never appear in the predicates + * of generated activities. */ persist(silent = false) { const system = this.#system; const storage = this.#storage; + const { + objectProperties, + transitiveProperties, + dataProperties, + } = this.#schema; const persistedData = this.#persistedData; const data = this.#data; const diffs = {}; for (const [key, value] of Object.entries(data)) { // Iterate over each entry of the tag data and create a diff // with the last persisted information. - if (SKIP_IN_DIFF.has(key) || silent && LITERAL_TERMS.has(key)) { + if ( + objectProperties[key]?.inverseOf != null || + silent && key in dataProperties + ) { // The current property is one which is skipped in diffs. // // In a silent persist, this includes any literal terms. @@ -1097,24 +675,13 @@ class Tag { }; } else if (value instanceof Set) { // The current property is set‐valued. - let values = null; // initialized on first use const oldValues = new Set(persisted); const newValues = new Set(value); for (const existing of persisted) { // Iterate over each persisted property and either remove // it from the list of new values or add it to the list of // removed ones. - // - // ※ Some special handling is required here for - // language‐tagged strings. - if ( - value.has(existing) || - Object(existing) === existing && - (values ??= [...value]).some(($) => - `${$}` == `${existing}` && - $.language == existing.language - ) - ) { + if (value.has(existing)) { // The value is in both the old and new version of the // data. oldValues.delete(existing); @@ -1124,10 +691,7 @@ class Tag { /* do nothing */ } } - diffs[key] = { - old: oldValues, - new: newValues, - }; + diffs[key] = { old: oldValues, new: newValues }; } else if ( `${value}` != `${persisted}` || value.language != persisted.language @@ -1140,10 +704,7 @@ class Tag { }; } else { // The current property did not change. - diffs[key] = { - old: new Set(), - new: new Set(), - }; + diffs[key] = { old: new Set(), new: new Set() }; } } } @@ -1159,38 +720,41 @@ class Tag { } const persistedIdentifier = this.#identifier; this.#persistedData = tagData(data); // cloning here is necessary - for ( - const [term, inverse] of [ - ["broader", "narrower"], - ["inCanon", "hasInCanon"], - ["involves", "involvedIn"], - ] - ) { - // Iterate over each term referencing other tags and update the - // inverse property on those tags if possible. - for (const referencedIdentifier of diffs[term].old) { - // Iterate over the removed tags and remove this `Tag` from - // their inverse property. - const referenced = storage.get(referencedIdentifier); - try { - // Try removing this `Tag`. - referenced.#data[inverse].delete(persistedIdentifier); - storage.set(referencedIdentifier, referenced); - } catch { - // Removal failed, possibly because the other tag was - // deleted. - /* do nothing */ + for (const inverse in objectProperties) { + // Iterate over each non‐transitive inverse property and update + // it based on its inverse on the corresponding tags if possible. + const term = objectProperties[inverse].inverseOf; + if (term == null || term in transitiveProperties) { + // The current property is not the inverse of an non‐transitive + // property. + /* do nothing */ + } else { + // The current property is the inverse of a non‐transitive + // property. + for (const referencedIdentifier of diffs[term].old) { + // Iterate over the removed tags and remove this `Tag` from + // their inverse property. + const referenced = storage.get(referencedIdentifier); + try { + // Try removing this `Tag`. + referenced.#data[inverse].delete(persistedIdentifier); + storage.set(referencedIdentifier, referenced); + } catch { + // Removal failed, possibly because the other tag was + // deleted. + /* do nothing */ + } } - } - for (const referencedIdentifier of diffs[term].new) { - const referenced = storage.get(referencedIdentifier); - try { - // Try adding this `Tag`. - referenced.#data[inverse].add(persistedIdentifier); - storage.set(referencedIdentifier, referenced); - } catch { - // Adding failed, possibly because the other tag was deleted. - /* do nothing */ + for (const referencedIdentifier of diffs[term].new) { + const referenced = storage.get(referencedIdentifier); + try { + // Try adding this `Tag`. + referenced.#data[inverse].add(persistedIdentifier); + storage.set(referencedIdentifier, referenced); + } catch { + // Adding failed, possibly because the other tag was deleted. + /* do nothing */ + } } } } @@ -1232,14 +796,11 @@ class Tag { // things as needed. for (const oldValue of oldValues) { // Iterate over removals and unstate them. - if (LITERAL_TERMS.has(term)) { - // This is a literal term; push the change wrapped in an - // object. + if (term in dataProperties) { + // This is a literal term; push it. unstates.push({ predicate: term, - object: Object(oldValue) === oldValue - ? { ...langString(oldValue) } - : { "@value": `${oldValue}` }, + object: { ...oldValue }, }); } else { // This is a named term; attempt to get its I·R·I and @@ -1266,14 +827,11 @@ class Tag { } for (const newValue of newValues) { // Iterate over additions and state them. - if (LITERAL_TERMS.has(term)) { - // This is a literal term; push the change wrapped in an - // object. + if (term in dataProperties) { + // This is a literal term; push it. states.push({ predicate: term, - object: Object(newValue) === newValue - ? { ...langString(newValue) } - : { "@value": `${newValue}` }, + object: { ...newValue }, }); } else { // This is a named term; attempt to get its I·R·I and @@ -1372,6 +930,228 @@ class Tag { } } +const { + /** + * Returns whether the provided schema, subject class, object + * property, and object class are consistent. + * + * This is hardly a full reasoner; it is tuned to the abilites and + * needs of this module. + */ + isObjectPredicateOK, +} = (() => { + const cachedClassAndSuperclasses = new WeakMap(); + const cachedClassRestrictions = new WeakMap(); + const cachedPredicateRestrictions = new WeakMap(); + + const classAndSuperclasses = function* ( + classes, + baseClass, + touched = new Set(), + ) { + if (baseClass == "Thing" || touched.has(baseClass)) { + /* do nothing */ + } else { + yield baseClass; + touched.add(baseClass); + const subClassOf = classes[baseClass]?.subClassOf ?? "Thing"; + for ( + const superclass of ( + typeof subClassOf == "string" + ? [subClassOf] + : Array.from(subClassOf) + ).filter(($) => typeof $ == "string") + ) { + yield* classAndSuperclasses(classes, superclass, touched); + } + } + }; + + const getClassAndSuperclasses = (schema, baseClass) => { + const schemaCache = cachedClassAndSuperclasses.get(schema); + const cached = schemaCache?.[baseClass]; + if (cached != null) { + return cached; + } else { + const { classes } = schema; + const result = [...classAndSuperclasses(classes, baseClass)]; + if (schemaCache) { + schemaCache[baseClass] = result; + } else { + cachedClassRestrictions.set( + schema, + Object.assign(Object.create(null), { [baseClass]: result }), + ); + } + return result; + } + }; + + const getClassRestrictions = (schema, domain) => { + const schemaCache = cachedClassRestrictions.get(schema); + const cached = schemaCache?.[domain]; + if (cached != null) { + return cached; + } else { + const { classes } = schema; + const restrictions = Object.create(null); + const subClassOf = classes[domain]?.subClassOf ?? "Thing"; + for ( + const superclass of ( + typeof subClassOf == "string" + ? [subClassOf] + : Array.from(subClassOf) + ).filter(($) => Object($) === $) + ) { + const { onProperty, allValuesFrom } = superclass; + restrictions[onProperty] = processSpace(allValuesFrom); + } + if (schemaCache) { + schemaCache[domain] = restrictions; + } else { + cachedClassRestrictions.set( + schema, + Object.assign(Object.create(null), { + [domain]: restrictions, + }), + ); + } + return restrictions; + } + }; + + const getPredicateRestrictions = (schema, predicate) => { + const schemaCache = cachedPredicateRestrictions.get(schema); + const cached = schemaCache?.[predicate]; + if (cached != null) { + return cached; + } else { + const { objectProperties } = schema; + const restrictions = [ + ...predicateRestrictions(objectProperties, predicate), + ].reduce( + (result, { domainIntersection, rangeIntersection }) => { + result.domainIntersection.push(...domainIntersection); + result.rangeIntersection.push(...rangeIntersection); + return result; + }, + Object.assign(Object.create(null), { + domainIntersection: [], + rangeIntersection: [], + }), + ); + if (schemaCache) { + schemaCache[predicate] = restrictions; + } else { + cachedPredicateRestrictions.set( + schema, + Object.assign(Object.create(null), { + [predicate]: restrictions, + }), + ); + } + return restrictions; + } + }; + + const processSpace = (space) => + Object(space) === space + ? "length" in space + ? Array.from( + space, + (subspace) => + Object(subspace) === subspace + ? Array.from(subspace.unionOf) + : [subspace], + ) + : [Array.from(space.unionOf)] + : [[space]]; + + const predicateRestrictions = function* ( + objectProperties, + predicate, + touched = new Set(), + ) { + if (predicate == "Property" || touched.has(predicate)) { + /* do nothing */ + } else { + const { domain, range, subPropertyOf } = + objectProperties[predicate]; + yield Object.assign(Object.create(null), { + domainIntersection: processSpace(domain ?? "Thing"), + rangeIntersection: processSpace(range ?? "Thing"), + }); + touched.add(predicate); + for ( + const superproperty of ( + subPropertyOf == null + ? ["Property"] + : typeof subPropertyOf == "string" + ? [subPropertyOf] + : Array.from(subPropertyOf) + ) + ) { + yield* predicateRestrictions( + objectProperties, + superproperty, + touched, + ); + } + } + }; + + return { + isObjectPredicateOK: ( + schema, + subjectClass, + predicate, + objectClass, + ) => { + const { objectProperties } = schema; + const predicateDefinition = objectProperties[predicate]; + const isInverse = "inverseOf" in predicateDefinition; + const usedPredicate = isInverse + ? predicateDefinition.inverseOf + : predicate; + const domain = isInverse ? objectClass : subjectClass; + const domains = new Set(getClassAndSuperclasses(schema, domain)); + const ranges = new Set(getClassAndSuperclasses( + schema, + isInverse ? subjectClass : objectClass, + )); + const predicateRestrictions = getPredicateRestrictions( + schema, + usedPredicate, + ); + const { domainIntersection } = predicateRestrictions; + const rangeIntersection = [ + ...predicateRestrictions.rangeIntersection, + ...function* () { + for (const domain of domains) { + const classRestrictionOnPredicate = + getClassRestrictions(schema, domain)[usedPredicate]; + if (classRestrictionOnPredicate != null) { + yield* classRestrictionOnPredicate; + } else { + /* do nothing */ + } + } + }(), + ]; + return domainIntersection.every((domainUnion) => + domainUnion.some((domain) => + domain == "Thing" || domains.has(domain) + ) + ) && + rangeIntersection.every((rangeUnion) => + rangeUnion.some((range) => + range == "Thing" || ranges.has(range) + ) + ); + }, + }; +})(); + const { /** * Returns the provided value converted into a `String` object with @@ -1669,47 +1449,7 @@ export class TagSystem { } else { // No bound constructor has been created yet. const storage = this.#storage; - const BoundTag = Tag.bind(undefined, this, storage); - return this.#Tag = Object.defineProperties(BoundTag, { - all: { - configurable: true, - enumerable: false, - value: Tag.all.bind(BoundTag, this, storage), - writable: true, - }, - fromIRI: { - configurable: true, - enumerable: false, - value: Tag.fromIRI.bind(BoundTag, this, storage), - writable: true, - }, - fromIdentifier: { - configurable: true, - enumerable: false, - value: Tag.fromIdentifier.bind(BoundTag, this, storage), - writable: true, - }, - fromTagURI: { - configurable: true, - enumerable: false, - value: Tag.fromTagURI.bind(BoundTag, this, storage), - writable: true, - }, - identifiers: { - configurable: true, - enumerable: false, - value: Tag.identifiers.bind(BoundTag, this, storage), - writable: true, - }, - name: { value: `${this.tagURI}#${Tag.name}` }, - prototype: { value: Tag.prototype }, - [Storage.toInstance]: { - configurable: true, - enumerable: false, - value: Tag[Storage.toInstance].bind(BoundTag, this, storage), - writable: true, - }, - }); + return this.#Tag = Tag.For(this, storage, schema); } } diff --git a/model.test.js b/model.test.js index 3e9982a..74f73da 100644 --- a/model.test.js +++ b/model.test.js @@ -140,6 +140,12 @@ describe("TagSystem", () => { }); }); + describe(".For", () => { + it("[[Has]] is not present", () => { + assertFalse("For" in Tag); + }); + }); + describe(".all", () => { it("[[Call]] yields all the persisted tags", () => { const tags = new Set(function* () { @@ -411,8 +417,10 @@ describe("TagSystem", () => { }); it("[[Call]] throws when this is not a tag which can be placed in canon", () => { + const canon = new Tag("CanonTag"); + canon.persist(); assertThrows(() => { - new Tag().addInCanonTag(); + new Tag().addInCanonTag(canon); }); }); @@ -469,8 +477,10 @@ describe("TagSystem", () => { }); it("[[Call]] throws when this is not a conceptual tag", () => { + const involved = new Tag(); + involved.persist(); assertThrows(() => { - new Tag().addInvolvesTag(); + new Tag().addInvolvesTag(involved); }); }); diff --git a/schema.js b/schema.js new file mode 100644 index 0000000..c708cfe --- /dev/null +++ b/schema.js @@ -0,0 +1,114 @@ +// 📧🏷️ Étiquette ∷ schema.js +// ==================================================================== +// +// Copyright © 2023 Lady [@ Lady’s Computer]. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at . +// +// ___ +// +// ※ The definitions in this file are minimal and only really geared +// towards supporting the functionality that 📧🏷️ Étiquette needs. + +/** + * Supported class types. + * + * The class `"Thing"` is not defined, but is used as a generic + * superclass. + */ +const classes = { + Tag: { subClassOf: "Thing" }, + CanonTag: { subClassOf: "Tag" }, + ConceptualTag: { subClassOf: "Tag" }, + RelationshipTag: { + subClassOf: ["ConceptualTag", { + onProperty: "involves", + allValuesFrom: { + unionOf: ["CharacterTag", "RelationshipTag"], + }, + }], + }, + FriendshipTag: { subClassOf: "RelationshipTag" }, + RivalryTag: { subClassOf: "RelationshipTag" }, + FamilialRelationshipTag: { subClassOf: "RelationshipTag" }, + RomanticRelationshipTag: { subClassOf: "RelationshipTag" }, + SexualRelationshipTag: { subClassOf: "RelationshipTag" }, + EntityTag: { subClassOf: "Tag" }, + CharacterTag: { subClassOf: "EntityTag" }, + InanimateEntityTag: { subClassOf: "EntityTag" }, + GenreTag: { subClassOf: "Tag" }, + SettingTag: { subClassOf: "Tag" }, + LocationTag: { subClassOf: "SettingTag" }, + TimePeriodTag: { subClassOf: "SettingTag" }, + UniverseTag: { subClassOf: "SettingTag" }, +}; + +/** Supported transitive object properties. */ +const transitiveProperties = { + broaderTransitive: { domain: "Tag", range: "Tag" }, + narrowerTransitive: { inverseOf: "broaderTransitive" }, +}; + +/** Supported object properties. */ +const objectProperties = { + broader: { subPropertyOf: "broaderTransitive" }, + narrower: { + inverseOf: "broader", + subPropertyOf: "narrowerTransitive", + }, + inCanon: { + domain: { unionOf: ["EntityTag", "SettingTag"] }, + range: "CanonTag", + }, + hasInCanon: { inverseOf: "inCanon" }, + involves: { domain: "ConceptualTag", range: "Tag" }, + involvedIn: { inverseOf: "involves" }, + ...transitiveProperties, +}; + +/** Supported data properties. */ +const dataProperties = { + prefLabel: { domain: "Thing", range: "PlainLiteral" }, + altLabel: { domain: "Thing", range: "PlainLiteral" }, + hiddenLabel: { domain: "Thing", range: "PlainLiteral" }, +}; + +/** + * Returns an immutable, null‐prototype object deeply derived from the + * provided one. + * + * ※ Once records and tuples are added to Ecmascript, the schema + * should be defined in terms of those primitives. In the meantime, + * this function at least ensures the schema is Very Immutable. + */ +const makeRecord = ($) => { + return Object.preventExtensions( + Object.create( + null, + Object.fromEntries([...function* () { + for (const [key, value] of Object.entries($)) { + if (Object(value) === value) { + const recordValue = makeRecord(value); + yield [key, { enumerable: true, value: recordValue }]; + } else { + yield [key, { enumerable: true, value }]; + } + } + if (Array.isArray($)) { + yield ["length", { value: $.length }]; + } else { + /* do nothing */ + } + }()]), + ), + ); +}; + +export default makeRecord({ + classes, + objectProperties, + transitiveProperties, + dataProperties, +});