Skip to content

Commit

Permalink
tree: Promote schemaCreationUtilities to alpha (#20035)
Browse files Browse the repository at this point in the history
## Description

Promote schemaCreationUtilities to alpha, and a bit of polish for their
API and docs.
  • Loading branch information
CraigMacomber authored Oct 3, 2024
1 parent fba24a8 commit 5f9bbe0
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 43 deletions.
10 changes: 10 additions & 0 deletions .changeset/witty-suits-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"fluid-framework": minor
"@fluidframework/tree": minor
---

Expose experimental alpha APIs for producing schema from enums

`adaptEnum` and `enumFromStrings` have been added to `@fluidframework/tree/alpha` and `fluid-framework/alpha`.
These unstable alpha APIs are relatively simple helpers on-top of public APIs (source: [schemaCreationUtilities.ts](https://github.com/microsoft/FluidFramework/blob/main/packages/dds/tree/src/simple-tree/schemaCreationUtilities.ts)):
thus if these change or stable alternatives are needed, an application can replicate this functionality using these implementations as an example.
28 changes: 28 additions & 0 deletions packages/dds/tree/api-report/tree.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
```ts

// @alpha
export function adaptEnum<TScope extends string, const TEnum extends Record<string, string | number>>(factory: SchemaFactory<TScope>, members: TEnum): (<TValue extends TEnum[keyof TEnum]>(value: TValue) => TreeNode & {
readonly value: TValue;
}) & { readonly [Property in keyof TEnum]: TreeNodeSchemaClass<ScopedSchemaName<TScope, TEnum[Property]>, NodeKind.Object, TreeNode & {
readonly value: TEnum[Property];
}, never, true, unknown> & (new () => TreeNode & {
readonly value: TEnum[Property];
}); };

// @public
export type AllowedTypes = readonly LazyItem<TreeNodeSchema>[];

Expand Down Expand Up @@ -31,6 +40,15 @@ export interface CommitMetadata {
interface DefaultProvider extends ErasedType<"@fluidframework/tree.FieldProvider"> {
}

// @alpha
export function enumFromStrings<TScope extends string, const Members extends string>(factory: SchemaFactory<TScope>, members: readonly Members[]): (<TValue extends Members>(value: TValue) => TreeNode & {
readonly value: TValue;
}) & Record<Members, TreeNodeSchemaClass<ScopedSchemaName<TScope, Members>, NodeKind.Object, TreeNode & {
readonly value: Members;
}, never, true, unknown> & (new () => TreeNode & {
readonly value: Members;
})>;

// @public
type ExtractItemType<Item extends LazyItem> = Item extends () => infer Result ? Result : Item;

Expand Down Expand Up @@ -495,6 +513,13 @@ export type SharedTreeFormatVersion = typeof SharedTreeFormatVersion;
// @alpha
export type SharedTreeOptions = Partial<ICodecOptions> & Partial<SharedTreeFormatOptions> & ForestOptions;

// @alpha
export function singletonSchema<TScope extends string, TName extends string | number>(factory: SchemaFactory<TScope, TName>, name: TName): TreeNodeSchemaClass<ScopedSchemaName<TScope, TName>, NodeKind.Object, TreeNode & {
readonly value: TName;
}, never, true, unknown> & (new () => TreeNode & {
readonly value: TName;
});

// @public
export type TransactionConstraint = NodeInDocumentConstraint;

Expand Down Expand Up @@ -699,6 +724,9 @@ export interface TreeViewEvents {
// @alpha
export const typeboxValidator: JsonValidator;

// @alpha
export function typedObjectValues<TKey extends string, TValues>(object: Record<TKey, TValues>): TValues[];

// @public @deprecated
const typeNameSymbol: unique symbol;

Expand Down
3 changes: 1 addition & 2 deletions packages/dds/tree/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,11 @@ export {
// Recursive Schema APIs
type ValidateRecursiveSchema,
type FixRecursiveArraySchema,
// experimental @internal APIs:
// experimental @alpha APIs:
adaptEnum,
enumFromStrings,
singletonSchema,
typedObjectValues,
type EmptyObject,
// test recursive schema for checking that d.ts files handles schema correctly
test_RecursiveObject,
test_RecursiveObject_base,
Expand Down
1 change: 0 additions & 1 deletion packages/dds/tree/src/simple-tree/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export {
enumFromStrings,
singletonSchema,
typedObjectValues,
type EmptyObject,
} from "./schemaCreationUtilities.js";
export { treeNodeApi, type TreeNodeApi } from "./treeNodeApi.js";
export { createFromInsertable, cursorFromInsertable } from "./create.js";
Expand Down
91 changes: 55 additions & 36 deletions packages/dds/tree/src/simple-tree/api/schemaCreationUtilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,29 @@ import { fail } from "../../util/index.js";

import type { SchemaFactory, ScopedSchemaName } from "./schemaFactory.js";
import type { NodeFromSchema } from "../schemaTypes.js";
import type { NodeKind, TreeNodeSchemaClass, TreeNode } from "../core/index.js";
import type {
InsertableObjectFromSchemaRecord,
ObjectFromSchemaRecord,
} from "../objectNode.js";

/**
* Empty Object for use in type computations that should contribute no fields when `&`ed with another type.
* @internal
InternalTreeNode,
NodeKind,
TreeNode,
TreeNodeSchemaClass,
} from "../core/index.js";

/*
* This file does two things:
*
* 1. Provides tools for making schema for cases like enums.
*
* 2. Demonstrates the kinds of schema utilities apps can write.
* Nothing in here needs access to package internal APIs.
*/
// Using {} instead of interface {} or Record<string, never> for empty object here produces better IntelliSense in the generated types than `Record<string, never>` recommended by the linter.
// Making this a type instead of an interface prevents it from showing up in IntelliSense, and also avoids breaking the typing somehow.
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/consistent-type-definitions
export type EmptyObject = {};

/**
* Create a schema for a node with no state.
* @remarks
* This is commonly used in unions when the only information needed is which kind of node the value is.
* Enums are a common example of this pattern.
* @internal
* @see {@link adaptEnum}
* @alpha
*/
// Return type is intentionally derived.
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
Expand All @@ -38,17 +40,15 @@ export function singletonSchema<TScope extends string, TName extends string | nu
name: TName,
) {
class SingletonSchema extends factory.object(name, {}) {
public constructor(data?: EmptyObject) {
public constructor(data?: InternalTreeNode) {
super(data ?? {});
}
public get value(): TName {
return name;
}
}

type NodeType = object &
TreeNode &
ObjectFromSchemaRecord<EmptyObject> & { readonly value: TName };
type NodeType = TreeNode & { readonly value: TName };

// Returning SingletonSchema without a type conversion results in TypeScript generating something like `readonly "__#124291@#brand": unknown;`
// for the private brand field of TreeNode.
Expand All @@ -59,7 +59,7 @@ export function singletonSchema<TScope extends string, TName extends string | nu
ScopedSchemaName<TScope, TName>,
NodeKind.Object,
NodeType,
object & InsertableObjectFromSchemaRecord<EmptyObject>,
never,
true
> &
(new () => NodeType) = SingletonSchema;
Expand All @@ -71,33 +71,50 @@ export function singletonSchema<TScope extends string, TName extends string | nu
* Converts an enum into a collection of schema which can be used in a union.
* @remarks
* Currently only supports `string` enums.
* The string value of the enum is used as the name of the schema: callers must ensure that it is stable and unique.
* Consider making a dedicated schema factory with a nested scope to avoid the enum members colliding with other schema.
* @example
* ```typescript
* const schemaFactory = new SchemaFactory("com.myApp");
* // An enum for use in the tree. Must have string keys.
* enum Mode {
* a = "A",
* b = "B",
* }
* const ModeNodes = adaptEnum(schema, Mode);
* // Define the schema for each member of the enum using a nested scope to group them together.
* const ModeNodes = adaptEnum(new SchemaFactory(`${schemaFactory.scope}.Mode`), Mode);
* // Defined the types of the nodes which correspond to this the schema.
* type ModeNodes = NodeFromSchema<(typeof ModeNodes)[keyof typeof ModeNodes]>;
* const nodeFromString: ModeNodes = ModeNodes(Mode.a);
* const nodeFromSchema: ModeNodes = new ModeNodes.a();
* const nameFromNode: Mode = nodeFromSchema.value;
* // An example schema which has an enum as a child.
* class Parent extends schemaFactory.object("Parent", {
* // typedObjectValues extracts a list of all the fields of ModeNodes, which are the schema for each enum member.
* // This means any member of the enum is allowed in this field.
* mode: typedObjectValues(ModeNodes),
* }) {}
*
* // Example usage of enum-based nodes, showing what type to use and that `.value` can be used to read out the enum value.
* function getValue(node: ModeNodes): Mode {
* return node.value;
* }
*
* // Example constructing a tree containing an enum node from an enum value.
* // The syntax `new ModeNodes.a()` is also supported.
* function setValue(node: Parent): void {
* node.mode = ModeNodes(Mode.a);
* }
* ```
* @privateRemarks
* TODO:
* Extends this to support numeric enums.
* Maybe require an explicit nested scope to group them under, or at least a warning about collisions.
* Maybe just provide `SchemaFactory.nested` to east creating nested scopes?
* @internal
* Extend this to support numeric enums.
* Maybe provide `SchemaFactory.nested` to ease creating nested scopes?
* @see {@link enumFromStrings} for a similar function that works on arrays of strings instead of an enum.
* @alpha
*/
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function adaptEnum<TScope extends string, const TEnum extends Record<string, string>>(
factory: SchemaFactory<TScope>,
members: TEnum,
) {
export function adaptEnum<
TScope extends string,
const TEnum extends Record<string, string | number>,
>(factory: SchemaFactory<TScope>, members: TEnum) {
type Values = TEnum[keyof TEnum];
const values = Object.values(members) as Values[];
const inverse = new Map(Object.entries(members).map(([key, value]) => [value, key])) as Map<
Expand All @@ -116,7 +133,7 @@ export function adaptEnum<TScope extends string, const TEnum extends Record<stri
};
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const factoryOut = <TValue extends Values>(value: TValue) => {
return new out[inverse.get(value) ?? fail("missing enum value")]({}) as NodeFromSchema<
return new out[inverse.get(value) ?? fail("missing enum value")]() as NodeFromSchema<
ReturnType<typeof singletonSchema<TScope, TValue>>
>;
};
Expand All @@ -135,7 +152,9 @@ export function adaptEnum<TScope extends string, const TEnum extends Record<stri

/**
* `Object.values`, but with more specific types.
* @internal
* @remarks
* Useful with collections of schema, like those returned by {@link adaptEnum} or {@link enumFromStrings}.
* @alpha
*/
export function typedObjectValues<TKey extends string, TValues>(
object: Record<TKey, TValues>,
Expand All @@ -160,7 +179,8 @@ export function typedObjectValues<TKey extends string, TValues>(
*
* class Parent extends schemaFactory.object("Parent", { mode: typedObjectValues(Mode) }) {}
* ```
* @internal
* @see {@link adaptEnum} for a similar function that works on enums instead of arrays of strings.
* @alpha
*/
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function enumFromStrings<TScope extends string, const Members extends string>(
Expand All @@ -175,7 +195,7 @@ export function enumFromStrings<TScope extends string, const Members extends str
type TOut = Record<Members, ReturnType<typeof singletonSchema<TScope, Members>>>;
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const factoryOut = <TValue extends Members>(value: TValue) => {
return new out[value]({}) as NodeFromSchema<
return new out[value]() as NodeFromSchema<
ReturnType<typeof singletonSchema<TScope, TValue>>
>;
};
Expand All @@ -192,8 +212,7 @@ export function enumFromStrings<TScope extends string, const Members extends str
return out;
}

// TODO: Why does this one generate an invalid d.ts file if exported?
// Tracked by https://github.com/microsoft/TypeScript/issues/58688
// TODO: This generates an invalid d.ts file if exported due to a bug https://github.com/microsoft/TypeScript/issues/58688.
// TODO: replace enumFromStrings above with this simpler implementation when the TypeScript bug is resolved.
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function _enumFromStrings2<TScope extends string, const Members extends readonly string[]>(
Expand Down
1 change: 0 additions & 1 deletion packages/dds/tree/src/simple-tree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export {
enumFromStrings,
singletonSchema,
typedObjectValues,
type EmptyObject,
test_RecursiveObject,
test_RecursiveObject_base,
test_RecursiveObjectPojoMode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("schemaCreationUtilities", () => {
const view: TreeView<typeof Parent> = tree.viewWith(config);
view.initialize(
new Parent({
mode: new Mode.Bonus({}),
mode: new Mode.Bonus(),
}),
);
const mode = view.root.mode;
Expand Down Expand Up @@ -74,6 +74,36 @@ describe("schemaCreationUtilities", () => {
class Parent extends schemaFactory.object("Parent", { mode: typedObjectValues(Mode) }) {}
});

it("adaptEnum example from docs", () => {
const schemaFactory = new SchemaFactory("com.myApp");
// An enum for use in the tree. Must have string keys.
enum Mode {
a = "A",
b = "B",
}
// Define the schema for each member of the enum using a nested scope to group them together.
const ModeNodes = adaptEnum(new SchemaFactory(`${schemaFactory.scope}.Mode`), Mode);
// Defined the types of the nodes which correspond to this the schema.
type ModeNodes = NodeFromSchema<(typeof ModeNodes)[keyof typeof ModeNodes]>;
// An example schema which has an enum as a child.
class Parent extends schemaFactory.object("Parent", {
// typedObjectValues extracts a list of all the fields of ModeNodes, which are the schema for each enum member.
// This means any member of the enum is allowed in this field.
mode: typedObjectValues(ModeNodes),
}) {}

// Example usage of enum based nodes, showing what type to use and that `.value` can be used to read out the enum value.
function getValue(node: ModeNodes): Mode {
return node.value;
}

// Example constructing a tree containing an enum node from an enum value.
// The syntax `new ModeNodes.a()` is also supported.
function setValue(node: Parent): void {
node.mode = ModeNodes(Mode.a);
}
});

it("adaptEnum example", () => {
const schemaFactory = new SchemaFactory("x");

Expand Down Expand Up @@ -112,7 +142,7 @@ describe("schemaCreationUtilities", () => {
const view: TreeView<typeof Parent> = tree.viewWith(config);
view.initialize(
new Parent({
mode: new Mode.Bonus({}),
mode: new Mode.Bonus(),
}),
);
const mode = view.root.mode;
Expand Down Expand Up @@ -185,7 +215,7 @@ describe("schemaCreationUtilities", () => {
// Can convert enum to unhydrated node:
const x = DayNodes(Day.Today);
// Can construct unhydrated node from enum's key:
const y = new DayNodes.Today({});
const y = new DayNodes.Today();

const view = tree.viewWith(
new TreeViewConfiguration({ schema: typedObjectValues(DayNodes) }),
Expand Down
Loading

0 comments on commit 5f9bbe0

Please sign in to comment.