composite-collection v2.0.2
composite-collection
Composing Maps, WeakMaps, Sets and WeakSets into generated classes.
Summary
How often do you find yourself writing code like this?
function setTwoKeyValue(key1, key2, value) {
if (!outerMap.has(key1)) {
outerMap.set(key1, new WeakMap);
}
const innerMap = outerMap.get(key1);
innerMap.set(key2, value);
}If the answer is "a lot", this package is for you. It'd be much nicer to just write:
compositeWeakWeakMap.set(key1, key2, value);TypeScript has the ability to specify argument types via generic classes. This package creates and supports generic TypeScript classes too:
import OneToOneStrongMap from "../collections/OneToOneStrongMap.mjs";
const a: OneToOneStrongMap<ClassOne, string> = new OneToOneStrongMap;
const oneA = new ClassOne, oneB = new ClassOne;
a.bindOneToOne("red", oneA, "blue", oneB);
a.get(oneA, "blue"); // returns oneBThe composite-collection package provides several pre-defined two-key collection classes for your use:
- composite-collection/StrongStrongMap
- composite-collection/StrongStrongSet
- composite-collection/WeakWeakMap
- composite-collection/WeakStrongMap
- composite-collection/WeakWeakSet
- composite-collection/WeakStrongSet
- composite-collection/StrongMapOfStrongSets
- composite-collection/WeakMapOfStrongSets
- composite-collection/OneToOneSimpleMap
- composite-collection/OneToOneStrongMap
- composite-collection/OneToOneWeakMap
If you want to generate your own composite collections, this package is also for you. Each of the above collections comes from a short configuration file, some hand-written templates, and a code-generating set of modules to transform the templates into working collection modules, complete with JSDoc comments. Here's the WeakMapOfStrongSets configuration file:
import CollectionConfiguration from "composite-collection/Configuration";
const WeakMapOfStrongSetsConfig = new CollectionConfiguration("WeakMapOfStrongSets", "WeakMap", "Set");
WeakMapOfStrongSetsConfig.addMapKey("mapKey", "The map key.", true);
WeakMapOfStrongSetsConfig.addSetKey("setKey", "The set key.", false);
WeakMapOfStrongSetsConfig.lock();
export default WeakMapOfStrongSetsConfig;Here's code you could use to generate this collection.
import CompositeDriver from "composite-collection/Driver";
import CompileTimeOptions from "composite-collection/CompileTimeOptions";
import path from "path";
const options = new CompileTimeOptions({
licenseText: "// insert your license text here, commented out",
license: "short-license-string for JSDoc",
author: "author name <author e-mail>",
copyright: "© copyright string",
});
const driver = new CompositeDriver(
path.join(process.cwd(), "configurations"),
path.join(process.cwd(), "collections"),
options
);
driver.start();
await driver.run();
// at this point, "./collections/WeakMapOfStrongSets.mjs" has everything you needTo use it (TypeScript):
import WeakMapOfStrongSets from "./collections/WeakMapOfStrongSets.mjs";
const wfMM: WeakMapOfStrongSets<object, () => void> = new WeakMapOfStrongSets();
const key1 = {}, callback1 = function() {}, callback2 = function() {};
wfMM.add(key1, callback1);
wfMM.add(key1, callback2);
const key3 = {}, callback3 = function() {};
wfMM.add(key3, callback3);
wfMM.forEachSet(key1, (key, callback) => {
void(key);
callback();
});
/*
This executes callback1() and callback2(), in that order, but not callback3().
*/The CompileTimeOptions modify the generated output to include metadata such as the license, author and copyright.
Definitions
A composite collection is a class to implement two- or three-key maps and sets. (Or any number of keys you need.) If you only need one key, this technically is not a composite collection, but this library still supports this use-case for key and value validation. The ordering of keys is significant:
compositeWeakWeakSet.add(key1, key2, value);
compositeWeakWeakSet.has(key2, key1); // returns false
compositeWeakWeakSet.has(key1, key2); // returns true
// The user must provide both keys the collection requires, in the right order.
compositeWeakWeakSet.has(key1); // return false. In TypeScript, this will not compile.
compositeWeakWeakSet.has(key2); // return false. In TypeScript, this will not compile.Strong and weak references
By a "strong" key, I mean the collection holds a strong reference to the argument. This means that unless the user explicitly deletes the key in the collection, or the collection itself is inaccessible, the argument will remain held in memory.
By a "weak" key, I mean the collection does not hold a strong reference to the argument. Any such arguments are unreachable by JavaScript code, if there are no other variables or objects holding a reference to them. The JavaScript engine may delete unreachable and any objects they alone reference at any time.
The developer.mozilla.org website has a great explainer about weak and strong references.
"Maps of sets", or, sets you can search
A "map of sets" is a special set data structure. Think of the map keys as the terms you search for, and the set keys as the values you store. When you use a method like .forEachSet() or .valuesSet(), the collection will iterate only over the elements matching the map keys you've provided. There are other methods for manipulating these subsets:
.addSets(_mapKeys_, [ _setKeys1_, _setKeys2_, ...]);.deleteSets(_mapKeys_).getSizeOfSet(_mapKeys_)
There's also a .forEachMap() method which allows you to iterate over the map keys only, and a .forEach() method to iterate over all keys.
Currently, this module supports strong maps of strong sets, and weak maps of strong sets.
Maps of weak sets don't make sense right now: they turn into glorified composite sets, providing only a little functionality you can replicate via subclassing in exchange for greater internal complexity (and probable memory leaks).
Guidelines for using this package
I suggest the following practices for developers using this package to follow.
- If you want one or two or even three of this package's exported collection modules, copy and paste the modules into your project as-is. They already have license boilerplate and JSDoc specifying the copyright and author information.
- Don't forget to copy the
exports/keysdirectory to your destination as well! Almost all the collection modules depend on the files in there.
- Don't forget to copy the
- If you are developing more complex collections,
npm install --save-dev composite-collectionas a development dependency.- Maintain a "configurations" subdirectory that has only
CollectionConfigurationexport modules in it. - Maintain a "collections" subdirectory to receive the modules which composite-collection generates. Feel free to import modules from this subdirectory and use them, but do not edit them.
- Invoke the
Driveronly when you need to rebuild the modules in your collections subdirectory. Sure, it's really fast at generating modules, but why add extra build steps? The collections themselves should be fairly stable.
- Maintain a "configurations" subdirectory that has only
- Consider using rollup.js to bundle files from the collections subdirectory into another subdirectory you import from, particularly if you only use one composite collection.
- Use
--format=esto preserve the ECMAScript Modules output.
- Use
- Feel free to file GitHub issues for support!
Features
Currently supported (version 2.0.0):
- TypeScript class modules with all the pieces you need
- Invoking
tscto generate the JavaScript modules from JavaScript directly if you wish Readonlyprefixes on TypeScript generic classes for read-only operations (ex.ReadonlyWeakStrongMap<object, string>)- Generating
.d.mtsand.mjs.mapfiles for declarations and source maps, respectively
- Invoking
- A simple configuration API
- Generating code and matching JSDoc comments
- Comprehensive API in each collection for setting, getting and iterating over entries
- Support for @license, @author, and @copyright tags via composite-collection/CompileTimeOptions
- Inserting license boilerplate at the top via the
.licenseTextproperty of CompileTimeOptions
- Support for multiple weak keys, multiple strong keys
- Argument validation
- Including user modules for types
- Maps, Sets and Maps of Strong Sets available
- Weak keys subject to garbage collection
- Pre-compiled collections available as exports
- Private class fields and methods
- Using WeakRef and FinalizationRegistry to reduce the number of WeakMaps
- One-to-one hash tables with two-part keys:
("red", redObj) <-> ("blue", blueObj) - Eliminating redundant use of KeyHasher, WeakKeyComposer when there's only one map key and/or one set key
A note about one-to-one hashtables
Frequently, we see one-to-one hashtables implemented very simply:
const map = new WeakMap;
const redObj = {}, blueObj = {};
// ...
map.set(redObj, blueObj);
map.set(blueObj, redObj);
// ...
map.get(redObj); // returns blueObj
map.get(blueObj); // returns redObjThe composite-collection/OneToOneSimpleMap module implements this with its bindOneToOne(value1, value2) method. Lookups via .get(value) point from the source value to the target value.
However, this misses an important bit of context: the namespace each object belongs to. You could easily declare a relationship of two tuples: ("red", redObj) === ("blue", blueObj). This tuple arrangement adds the missing context with minimal overhead.
More significantly, having a second argument in each tuple allows you to define other namespaces and other relationships: ("green", greenObj) === ("red", redObj).
The simple hashtable above can't do this. To support this, there are the composite-collection/OneToOneStrongMap and composite-collection/OneToOneWeakMap modules.
These modules work by wrapping an existing weak map collection and assuming ownership of a weak key argument. Under the hood, the redObj, blueObj and greenObj would all point to a single weak key, which then goes into a WeakStrongMap along with the string argument as the strong key. The values are then the original objects. The binding would happen by calling .bindOneToOne("red", redObj, "blue", blueObj). Going from blueObj to redObj is as simple as calling .get(blueObj, "red").
If you want a more complex hashtable structure (multiple keys, argument validation, etc.), you'll want to craft your own collection configuration. See source/exports/OneToOneWeakMap.mjs for an example.
Collection Configuration API: How To Create A Collection
new CollectionConfiguration(className, outerType, innerType);classNameis the exported class's name, a valid identifier.outerTypeis:- "Map" for strong maps
- "WeakMap" for weak maps
- "Set" for strong sets
- "WeakSet" for weak sets
innerTypeis:- "Set" for maps of strong sets
- Maps of weak sets are illegal because it's unclear when we would hold references to the strong map keys.
setFileOverview(overview);to set a top-level file overviewimportLines(blockOfTest);to specify top-level module importsaddMapKey(argumentName, description, holdWeak, options);to specify keys in order (one at a time)argumentNameis the name of the argument, a valid identifier.descriptionis the description of the argument for JSDoc.holdWeakis true if the key represents a weakly held key, false for a strongly held key.optionsis an object taking optional properties:jsDocTypeis a JSDoc-printable type for the argument.tsTypeis a TypeScript type for the argument.argumentValidatoris a lambda function with one argument, the same asargumentName, to validate the argument value in the class's methods.- The validator must not throw, and only return false when the validation for that argument fails. It must not do - or return - anything else.
- The CodeGenerator will combine all the argument validators into a single
isValidKey()function.
addSetKey(argumentName, description, holdWeak, options);to specify ordered keys for sets- The arguments for
addSetKeyare the same as foraddMapKey.
- The arguments for
setValueType(description, options);for maps, to specify the type of the value to store.descriptionis the description to provide to JSDoc.optionshas the same shape asaddMapKey's options.
lock();to lock the configuration.export defaultthe configuration.
One-to-one hashtables go through an additional set of steps.
const baseConfig = new CollectionConfiguration(className, "WeakMap");.- Fill this out as you normally would, with one weak key argument specifically reserved for the hashtable.
- Call
baseConfig.lock();. - You may import this configuration module instead of defining it inline if you wish.
- Do not export this configuration.
const hashTableConfig = new CollectionConfiguration(className, "OneToOne");for the hashtable.hashTableConfig.configureOneToOne(baseConfig, privateKeyName, options);baseConfigin exactly three cases may be a string, but all three cases have existing exports:- "WeakMap" to indicate a value-only hashtable. See composite-collection/OneToOneSimpleMap.
- "composite-collection/WeakStrongMap" to indicate a strong-key hashtable. See composite-collection/OneToOneStrongMap.
- "composite-collection/WeakWeakMap" to indicate a weak-key hashtable. See composite-collection/OneToOneWeakMap.
- Anything more complex (argument validation, value validation, more than one key, etc.) requires a custom base configuration.
privateKeyNameis the reserved weak key argument.optionsis an object taking optional properties:pathToBaseModuleis a module path to the base configuration module, for the generated code to import. This allows you to keep the base configuration in another file for theDriverto generate sepearately in the same directory.
lock();to lock the hashtable configuration.export defaultthe hashtable configuration.
How It All Works
- The user writes a CollectionConfiguration instance as I document above.
- The
templatesdirectory holds template JavaScript files in JavaScript template literals, enclosed in functions taking adefinesMap argument and at least onedocs"JSDocGenerator" argument. - For strongly held keys, the template specifies a
KeyHashermodule to import, which theDrivermodule copies into the destination directory. TheKeyHasherholds weak references to objects, and returns a string hash for the module's use. - For weakly held keys (and strongly held keys associated with them), the template specifies a
WeakKeyComposermodule to import. TheDrivermodule copies this module into the destination directory. TheWeakKeyComposerholds the weak and strong references as the collection specified. It returns vanilla objects (WeakKeyobjects) for the module's use. - The
CodeGeneratoruses the configuration and fills aJSDocGeneratorinstance with the necessary fields to format JSDoc comments for the code it will generate. - The
CodeGeneratorcombines the template, the configuration and theJSDocGeneratorinto a TypeScript module ending in.mts. The module willexport defaultthe final collection class. - The
CodeGeneratorinvokestscto transform the TypeScript module into a JavaScript module file ready for either web browsers or NodeJS applications to use. The module will storeWeakKeyobjects in a private WeakMap, and hashes in a private Map.