-
Notifications
You must be signed in to change notification settings - Fork 580
feat(tree): add alpha Component utilities for open-polymorphic schema #27628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
CraigMacomber
wants to merge
10
commits into
microsoft:main
Choose a base branch
from
CraigMacomber:componet
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
f89aff5
feat(tree): add alpha Component utilities for open-polymorphic schema
CraigMacomber 0c4a045
Fix changeset title formatting
CraigMacomber 84d6852
Better file location
CraigMacomber 73f9494
Tweaks, and more examples
CraigMacomber 8373b1c
fix comment phrasing
CraigMacomber b5b34e7
Update APi report
CraigMacomber 9a94c73
More example cleanup
CraigMacomber 0bc0fa0
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
CraigMacomber e4d480f
Add note about skips
CraigMacomber c4a9224
Add link
CraigMacomber File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| --- | ||
| "@fluidframework/tree": minor | ||
| "fluid-framework": minor | ||
| "__section": tree | ||
| --- | ||
| Add Component utilities for composing open-polymorphic schema | ||
|
|
||
| A new `@alpha` `Component` namespace is now exported from `@fluidframework/tree` (and re-exported from `fluid-framework`). It provides utilities for composing independently authored application "components" that contribute to a shared configuration, which is useful for implementing ["open polymorphism"](https://en.wikipedia.org/wiki/Polymorphism_(computer_science)) schema patterns where the set of allowed types for a field or collection can be extended by separate libraries. | ||
|
Check warning on line 8 in .changeset/component-composition-alpha.md
|
||
|
|
||
| Each component is expressed as a `Component.Factory`: a function which receives a lazy reference to the composed configuration and returns the content that component contributes. Because the configuration is provided lazily, components may reference (including recursively) types contributed by other components. `Component.composeComponents` combines a set of components into a `Component.ComposedComponents`, from which the aggregated configuration and per-component content can be read. | ||
|
Check warning on line 10 in .changeset/component-composition-alpha.md
|
||
|
|
||
| ```typescript | ||
| const composed = Component.composeComponents(allComponents, (c) => ({ | ||
| allowedItemTypes: c.getComposed("items"), | ||
| })); | ||
| ``` | ||
|
|
||
| See the worked examples in [openPolymorphism.integration.ts](https://github.com/microsoft/FluidFramework/blob/main/packages/dds/tree/src/test/openPolymorphism.integration.ts) for end-to-end usage with SharedTree schema. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,316 @@ | ||
| /*! | ||
| * Copyright (c) Microsoft Corporation and contributors. All rights reserved. | ||
| * Licensed under the MIT License. | ||
| */ | ||
|
|
||
| import { UsageError } from "@fluidframework/telemetry-utils/internal"; | ||
|
|
||
| import { getOrCreate } from "./util/index.js"; | ||
|
|
||
| /** | ||
| * Utilities for composing application "components" which contribute to a shared configuration. | ||
| * | ||
| * @remarks | ||
| * Nothing in this namespace is specific to tree schema, | ||
| * however it is designed to be able to handle the needs of components which work with {@link TreeSchema}. | ||
| * | ||
| * This is mainly used to implement "open polymorphism", where the set of allowed types | ||
| * for a field or collection can be extended by independently authored libraries (components) instead of being | ||
| * fixed up front. | ||
| * | ||
| * This basically amounts to dependency injection, where the "injection" is done at "composition" time to build the schema. | ||
| * | ||
| * Tree's schema do not natively support open polymorphism: all possible implementations must be explicitly listed. | ||
| * These tools work around these limitations by carefully controlling evaluation order: | ||
| * the source code can be structured in an open polymorphism style which at runtime evaluates into closed polymorphism | ||
| * by having each component register its implementations into a central collection (typically an | ||
| * {@link AllowedTypes} array). | ||
| * | ||
| * Each component is expressed as a {@link Component.Factory}: a function which receives a lazy reference to the | ||
| * composed configuration and returns the content that component contributes. | ||
| * Because the configuration is provided lazily, components may reference (including recursively) types contributed by | ||
| * other components, as long as nothing evaluates the lazy references until composition has completed. | ||
| * Use {@link Component.composeComponents} to combine a set of components into a {@link Component.ComposedComponents}. | ||
| * | ||
| * See {@link https://github.com/microsoft/FluidFramework/blob/main/packages/dds/tree/src/test/openPolymorphism.integration.ts|openPolymorphism.integration.ts} for worked examples of this pattern. | ||
| * | ||
| * @privateRemarks | ||
| * The examples and integration tests for this pattern live in `openPolymorphism.integration.ts`. | ||
| * Those tests and the docs here should be kept in sync. | ||
| * | ||
| * @alpha | ||
| */ | ||
| export namespace Component { | ||
|
Josmithr marked this conversation as resolved.
|
||
| /** | ||
| * A function which takes in a lazy configuration and returns the content a component contributes. | ||
| * | ||
| * @remarks | ||
| * The lazy configuration allows the component to reference items from the composed configuration, which can | ||
| * include items the component itself contributes (allowing recursive references between components). | ||
| * | ||
| * The execution of the factory must not evaluate `lazyConfiguration` (doing so will error): | ||
| * instead the returned `TComponent` can capture `lazyConfiguration` and evaluate it at a later time | ||
| * (after all components have been composed). | ||
| * | ||
| * @typeParam TConfig - The composed configuration type made available to components. | ||
| * @typeParam TComponent - The content a component contributes. | ||
| * | ||
| * @input | ||
| * @alpha | ||
| */ | ||
| export type Factory<TConfig, TComponent> = (lazyConfiguration: () => TConfig) => TComponent; | ||
|
|
||
| /** | ||
| * A function which returns an array of lazy values which each evaluate to `T`. | ||
| * | ||
| * @remarks | ||
| * This mirrors the shape of {@link AllowedTypes} where all of the values are lazy: | ||
| * the outer function and inner functions defer evaluation until after composition is complete. | ||
| * | ||
| * @typeParam T - The type each lazy value evaluates to. | ||
| * | ||
| * @alpha | ||
| */ | ||
| export type LazyArray<T> = () => readonly (() => T)[]; | ||
|
|
||
| /** | ||
| * Wrap a 0 argument function to cache the result. | ||
| * @remarks | ||
| * Do not use for impure functions. | ||
| * | ||
| * Generally {@link Component} utilities have built in caching in most cases, | ||
| * but this is occasionally helpful when manually implementing laziness. | ||
| * | ||
| * Note that this takes a different approach than use in {@link evaluateLazySchema} where the function evaluation is done using a utility that adds caching. | ||
| * | ||
| * @param factory - The factory function to memoize. | ||
| * @returns A function that returns the cached result of the factory. | ||
| * @alpha | ||
| */ | ||
| export const memoize = <T>(factory: () => T): (() => T) => { | ||
| let run = false; | ||
| let cached: T; | ||
| return () => { | ||
| if (!run) { | ||
| cached = factory(); | ||
| run = true; | ||
| } | ||
| return cached; | ||
| }; | ||
| }; | ||
|
|
||
| /** | ||
| * An item which can be configured (evaluated) against a composed configuration to produce a result. | ||
| * | ||
| * @remarks | ||
| * Use {@link Component.ComposedComponents.getConfigured} to evaluate a `Configurable`. | ||
| * The result is cached, so a given `Configurable` is only evaluated once per composition. | ||
| * | ||
| * @typeParam TConfigPartial - The configuration type made available when configuring. | ||
| * @typeParam TResult - The result produced by configuring. | ||
| * @typeParam TComponent - The content components contribute. | ||
| * | ||
| * @alpha | ||
| */ | ||
| export interface Configurable<TConfigPartial, out TResult, TComponent> { | ||
| /** | ||
| * Produce the configured result. | ||
| * @param config - The composed configuration. | ||
| * @param components - The composed components. | ||
| */ | ||
| configure( | ||
| config: TConfigPartial, | ||
| components: ComposedComponents<TConfigPartial, TComponent>, | ||
| ): TResult; | ||
| } | ||
|
|
||
| /** | ||
| * Implementation of {@link Component.ComposedComponents}. | ||
| */ | ||
| class Config<TConfig, TComponent> implements ComposedComponents<TConfig, TComponent> { | ||
| public readonly componentsMap: ReadonlyMap<Factory<TConfig, TComponent>, TComponent>; | ||
|
|
||
| /** | ||
| * Cache of results produced by {@link Component.ComposedComponents.getConfigured}. | ||
| * | ||
| * @remarks | ||
| * Maps each {@link Component.Configurable} to the result of evaluating it against this composition. | ||
| * This ensures a given `Configurable` is only configured once: subsequent lookups return the cached result. | ||
| */ | ||
| private readonly evaluatedMap: Map<Configurable<TConfig, unknown, TComponent>, unknown> = | ||
| new Map(); | ||
|
|
||
| /** | ||
| * Cache of results produced by {@link Component.ComposedComponents.getComposed}. | ||
| * | ||
| * @remarks | ||
| * Maps each composed property to the array produced for it. | ||
| * This ensures a given property is only composed once: subsequent lookups return the same array | ||
| * (including the same lazy values), so repeated calls with the same property are reference-stable. | ||
| */ | ||
| private readonly composedMap: Map<keyof TComponent, readonly unknown[]> = new Map(); | ||
|
|
||
| public readonly components: readonly TComponent[]; | ||
|
|
||
| public readonly config: TConfig; | ||
|
|
||
| public constructor( | ||
| allComponents: readonly Factory<TConfig, TComponent>[], | ||
| lazyConfiguration: (composed: ComposedComponents<TConfig, TComponent>) => TConfig, | ||
| ) { | ||
| // eslint-disable-next-line no-undef-init -- Explicitly undefined: `config` is populated below, after all components have been constructed, and is read lazily via `lazyConfigInner`. | ||
| let config: TConfig | undefined = undefined; | ||
| const lazyConfigInner = (): TConfig => { | ||
| if (config === undefined) { | ||
| throw new UsageError( | ||
| "Configuration not yet available: components must not evaluate the lazy configuration during composition.", | ||
| ); | ||
| } | ||
| return config; | ||
| }; | ||
| this.componentsMap = new Map(allComponents.map((c) => [c, c(lazyConfigInner)])); | ||
| this.components = [...this.componentsMap.values()]; | ||
| config = lazyConfiguration(this); | ||
| this.config = config; | ||
| } | ||
|
|
||
| public getComponent<TFactory extends Factory<TConfig, TComponent>>( | ||
| factory: TFactory, | ||
| ): ReturnType<TFactory> { | ||
| const found = this.componentsMap.get(factory); | ||
| if (found === undefined) { | ||
| throw new UsageError("Requested component not included in this configuration"); | ||
| } | ||
| return found as ReturnType<TFactory>; | ||
| } | ||
|
|
||
| public getConfigured<TConfigurable extends Configurable<TConfig, unknown, TComponent>>( | ||
| configurable: TConfigurable, | ||
| ): ReturnType<TConfigurable["configure"]> { | ||
| const configured: unknown = getOrCreate(this.evaluatedMap, configurable, (c) => { | ||
| const result = c.configure(this.config, this); | ||
| if (result === undefined) { | ||
| throw new UsageError("Configurable must not return undefined"); | ||
| } | ||
| return result; | ||
| }); | ||
| return configured as ReturnType<TConfigurable["configure"]>; | ||
| } | ||
|
|
||
| public getComposed< | ||
| TKey extends keyof { | ||
| [Property in keyof TComponent as TComponent[Property] extends | ||
| | LazyArray<unknown> | ||
| | undefined | ||
| ? Property | ||
| : never]: boolean; | ||
| }, | ||
| >( | ||
| property: TKey, | ||
| ): readonly (Exclude<TComponent[TKey], undefined> extends LazyArray<infer U> | ||
| ? () => U | ||
| : never)[] { | ||
| const result = getOrCreate(this.composedMap, property, () => | ||
| this.components.flatMap((c) => { | ||
| const prop = c[property] as LazyArray<unknown> | undefined; | ||
| if (prop === undefined) { | ||
| return []; | ||
| } | ||
| return prop(); | ||
| }), | ||
| ); | ||
| return result as (Exclude<TComponent[TKey], undefined> extends LazyArray<infer U> | ||
| ? () => U | ||
| : never)[]; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Combine multiple {@link Component.Factory|components} into a single {@link Component.ComposedComponents}. | ||
| * | ||
| * @param allComponents - The components to compose. | ||
| * @param lazyConfiguration - Builds the composed configuration from the composed components. | ||
| * This is invoked once, after all components have been created, and can aggregate content contributed by the | ||
| * components (for example via {@link Component.ComposedComponents.getComposed}). | ||
| * @returns The composed components, from which configuration and per-component content can be read. | ||
| * | ||
| * @typeParam TConfig - The composed configuration type made available to components. | ||
| * @typeParam TComponent - The content each component contributes. | ||
| * | ||
| * @alpha | ||
| */ | ||
| export function composeComponents<TConfig, TComponent>( | ||
| allComponents: readonly Factory<TConfig, TComponent>[], | ||
| lazyConfiguration: (composed: ComposedComponents<TConfig, TComponent>) => TConfig, | ||
| ): ComposedComponents<TConfig, TComponent> { | ||
| return new Config<TConfig, TComponent>(allComponents, lazyConfiguration); | ||
| } | ||
|
|
||
| /** | ||
| * The result of composing multiple components. | ||
| * | ||
| * @remarks | ||
| * Create using {@link Component.composeComponents}. | ||
| * | ||
| * @typeParam TConfig - The composed configuration type made available to components. | ||
| * @typeParam TComponent - The content each component contributes. | ||
| * | ||
| * @sealed @alpha | ||
| */ | ||
| export interface ComposedComponents<TConfig, TComponent> { | ||
| /** | ||
| * The components which were composed. | ||
| */ | ||
| readonly components: readonly TComponent[]; | ||
|
|
||
| /** | ||
| * The configuration which was produced when composing. | ||
| */ | ||
| readonly config: TConfig; | ||
|
|
||
| /** | ||
| * Get a component by its factory. | ||
| * | ||
| * @param factory - The factory used to look up the component. Must have been provided when composing. | ||
| * @returns The content created by the provided factory. | ||
| * This result is cached during composition and not reevaluated. | ||
| */ | ||
| getComponent<TFactory extends Factory<TConfig, TComponent>>( | ||
| factory: TFactory, | ||
| ): ReturnType<TFactory>; | ||
|
|
||
| /** | ||
| * Configure a {@link Component.Configurable}. | ||
| * @remarks | ||
| * The result is cached when first evaluated, so subsequent calls with the same `configurable` return the | ||
| * same result. | ||
| * @param configurable - The item to configure against this composition. | ||
| */ | ||
| getConfigured<TConfigurable extends Configurable<TConfig, unknown, TComponent>>( | ||
| configurable: TConfigurable, | ||
| ): ReturnType<TConfigurable["configure"]>; | ||
|
|
||
| /** | ||
| * Compose the contents of a {@link Component.LazyArray} property from all components. | ||
| * @remarks | ||
| * The result is cached when first evaluated, so subsequent calls with the same `property` return the | ||
| * same result. | ||
| * @param property - The property of the components to compose. | ||
| * @returns The concatenation of the lazy values contributed by each component for `property`. | ||
| * Components which omit the property contribute nothing. | ||
| */ | ||
| getComposed< | ||
| TKey extends keyof { | ||
| [Property in keyof TComponent as TComponent[Property] extends | ||
| | LazyArray<unknown> | ||
| | undefined | ||
| ? Property | ||
| : never]: boolean; | ||
| }, | ||
| >( | ||
| property: TKey, | ||
| ): readonly (Exclude<TComponent[TKey], undefined> extends LazyArray<infer U> | ||
| ? () => U | ||
| : never)[]; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Obviously not for this PR, but I wonder how easy it would be to expose a more restrictive, but easier to understand model on top of this that is explicitly Tree schema focused for the common use cases. We're trying to offer a lot of flexibility below, but I imagine the vast majority of users who would want to leverage a pattern like this for SharedTree would:
a) not need a lot of the flexibility
b) find leveraging them complex / potentially confusing.
If we offered a
TreeComponentAPI layer above this that was more prescriptive, it might give the average user an easier onboarding experience, and then they could move to the lower level alternatives as needed to address complex scenarios.Just a thought.