Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/tricky-shirts-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@fluidframework/tree": minor
"__section": feature
---
Added `Tree.ensureSchema`

This helper function allows content to be tagged with a schema type before being inserted into the tree.
This allows content that would otherwise be ambiguous to be well-defined, without having to wrap it in a node constructor.

Example:

```typescript
const sf = new SchemaFactory("example");
class Dog extends sf.object("Dog", { name: sf.string() }) {}
class Cat extends sf.object("Cat", { name: sf.string() }) {}
class Root extends sf.object("Root", { pet: [Dog, Cat] }) {}
// ...
const pet = { name: "Max" };
view.root.pet = pet; // Error: `pet` is ambiguous - is it a Dog or a Cat?
view.root.pet = new Dog(pet); // This works, but has the overhead of creating a Dog node before the insertion actually happens.
TreeAlpha.ensureSchema(Dog, pet); // Instead, this tags the `pet` object as a Dog...
view.root.pet = pet; // So now there is no error for a normal insertion - it's a Dog.
```

This function works by leveraging the new `schemaSymbol`, which is also available for use.
See its documentation for more information.
4 changes: 4 additions & 0 deletions packages/dds/tree/api-report/tree.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ export type ConciseTree<THandle = IFluidHandle> = Exclude<TreeLeafValue, IFluidH
// @beta
export function configuredSharedTreeBeta(options: SharedTreeOptionsBeta): SharedObjectKind<ITree>;

// @alpha
export const contentSchemaSymbol: unique symbol;

// @alpha
export function createIdentifierIndex<TSchema extends ImplicitFieldSchema>(view: TreeView<TSchema>): IdentifierIndex;

Expand Down Expand Up @@ -1361,6 +1364,7 @@ export interface TreeAlpha {
importConcise<const TSchema extends ImplicitFieldSchema | UnsafeUnknownSchema>(schema: UnsafeUnknownSchema extends TSchema ? ImplicitFieldSchema : TSchema & ImplicitFieldSchema, data: ConciseTree | undefined): Unhydrated<TSchema extends ImplicitFieldSchema ? TreeFieldFromImplicitField<TSchema> : TreeNode | TreeLeafValue | undefined>;
importVerbose<const TSchema extends ImplicitFieldSchema>(schema: TSchema, data: VerboseTree | undefined, options?: TreeParsingOptions): Unhydrated<TreeFieldFromImplicitField<TSchema>>;
key2(node: TreeNode): string | number | undefined;
tagContentSchema<TSchema extends TreeNodeSchema, TContent extends InsertableField<TSchema>>(schema: TSchema, content: TContent): TContent;
trackObservations<TResult>(onInvalidation: () => void, trackDuring: () => TResult): ObservationResults<TResult>;
trackObservationsOnce<TResult>(onInvalidation: () => void, trackDuring: () => TResult): ObservationResults<TResult>;
}
Expand Down
1 change: 1 addition & 0 deletions packages/dds/tree/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export {
type WithType,
type NodeChangedData,
type SchemaUpgrade,
contentSchemaSymbol,
// Types not really intended for public use, but used in links.
// Can not be moved to internalTypes since doing so causes app code to throw errors like:
// Error: src/simple-tree/objectNode.ts:72:1 - (ae-unresolved-link) The @link reference could not be resolved: The package "@fluidframework/tree" does not have an export "TreeNodeApi"
Expand Down
45 changes: 45 additions & 0 deletions packages/dds/tree/src/shared-tree/treeAlpha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import {
importConcise,
exportConcise,
borrowCursorFromTreeNodeOrValue,
contentSchemaSymbol,
type TreeNodeSchema,
} from "../simple-tree/index.js";
import { brand, extractFromOpaque, type JsonCompatible } from "../util/index.js";
import {
Expand Down Expand Up @@ -85,6 +87,7 @@ import {
} from "../feature-libraries/index.js";
import { independentInitializedView, type ViewContent } from "./independentView.js";
import { SchematizingSimpleTreeView, ViewSlot } from "./schematizingTreeView.js";
import { isFluidHandle } from "@fluidframework/runtime-utils";

const identifier: TreeIdentifierUtils = (node: TreeNode): string | undefined => {
const nodeIdentifier = getIdentifierFromNode(node, "uncompressed");
Expand Down Expand Up @@ -519,6 +522,33 @@ export interface TreeAlpha {
onInvalidation: () => void,
trackDuring: () => TResult,
): ObservationResults<TResult>;

/**
* Ensures that the provided content will be interpreted as the given schema when inserting into the tree.
* @returns `content`, for convenience.
* @remarks
* If applicable, this will tag the given content with a {@link contentSchemaSymbol | special property} that indicates its intended schema.
* The `content` will be interpreted as the given `schema` when later inserted into the tree.
* This does not validate that the content actually conforms to the given schema (such validation will be done at insert time).
*
* This is particularly useful when the content's schema cannot be inferred from its structure alone because it is compatible with multiple schemas.
* @example
* ```typescript
* const sf = new SchemaFactory("example");
* class Dog extends sf.object("Dog", { name: sf.string() }) {}
* class Cat extends sf.object("Cat", { name: sf.string() }) {}
* class Root extends sf.object("Root", { pet: [Dog, Cat] }) {}
* // ...
* const pet = { name: "Max" };
* view.root.pet = pet; // Error: ambiguous schema - is it a Dog or a Cat?
* TreeAlpha.ensureSchema(Dog, pet); // Tags `pet` as a Dog.
* view.root.pet = pet; // No error - it's a Dog.
* ```
*/
tagContentSchema<TSchema extends TreeNodeSchema, TContent extends InsertableField<TSchema>>(
schema: TSchema,
content: TContent,
): TContent;
}

/**
Expand Down Expand Up @@ -1003,6 +1033,21 @@ export const TreeAlpha: TreeAlpha = {
}
return result;
},

tagContentSchema<TSchema extends TreeNodeSchema, TNode extends InsertableField<TSchema>>(
schema: TSchema,
node: TNode,
): TNode {
if (typeof node === "object" && node !== null && !isFluidHandle(node)) {
Reflect.defineProperty(node, contentSchemaSymbol, {
configurable: false,
enumerable: false,
writable: true,
value: schema.identifier,
});
}
return node;
},
};

/**
Expand Down
7 changes: 6 additions & 1 deletion packages/dds/tree/src/simple-tree/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ export {
SimpleContextSlot,
withBufferedTreeEvents,
} from "./treeNodeKernel.js";
export { type WithType, typeNameSymbol, typeSchemaSymbol } from "./withType.js";
export {
type WithType,
typeNameSymbol,
typeSchemaSymbol,
contentSchemaSymbol,
} from "./withType.js";
export {
type Unhydrated,
type InternalTreeNode,
Expand Down
24 changes: 24 additions & 0 deletions packages/dds/tree/src/simple-tree/core/withType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

import type { TreeNode } from "./treeNode.js";
import type { NodeKind, TreeNodeSchemaClass } from "./treeNodeSchema.js";
// Used by doc links:
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports
import type { TreeAlpha } from "../../shared-tree/index.js";

/**
* The type of a {@link TreeNode}.
Expand Down Expand Up @@ -39,6 +42,27 @@ export const typeNameSymbol: unique symbol = Symbol("TreeNode Type");
*/
export const typeSchemaSymbol: unique symbol = Symbol("TreeNode Schema");

/**
* The intended type of insertable content that is to become a {@link TreeNode}.
* @remarks Use the type-safe {@link (TreeAlpha:interface).tagContentSchema} function to tag insertable content with this symbol.
*
* If a property with this symbol key is present on an object that is inserted into the tree,
* the tree will use the schema identifier specified by the value of this property when creating the node.
* This is particularly useful for specifying the intended schema of untyped content when it would otherwise be ambiguous.
* @example
* ```typescript
* const sf = new SchemaFactory("example");
* class Dog extends sf.object("Dog", { name: sf.string() }) {}
* class Cat extends sf.object("Cat", { name: sf.string() }) {}
* class Root extends sf.object("Root", { pet: [Dog, Cat] }) {}
* // ...
* view.root.pet = { name: "Max" }; // Error: ambiguous schema - is it a Dog or a Cat?
* view.root.pet = { name: "Max", [contentSchemaSymbol]: "example.Dog" }; // No error - it's a Dog.
* ```
* @alpha
*/
export const contentSchemaSymbol: unique symbol = Symbol("SharedTree Schema");

/**
* Adds a type symbol to a type for stronger typing.
*
Expand Down
1 change: 1 addition & 0 deletions packages/dds/tree/src/simple-tree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
export {
typeNameSymbol,
typeSchemaSymbol,
contentSchemaSymbol,
type WithType,
type TreeNodeSchema,
type AnnotatedAllowedType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
isTreeNode,
type TreeNode,
type TreeNodeSchema,
contentSchemaSymbol,
type Unhydrated,
UnhydratedFlexTreeNode,
} from "./core/index.js";
Expand Down Expand Up @@ -125,16 +126,28 @@ For class-based schema, this can be done by replacing an expression like "{foo:

/**
* Returns all types for which the data is schema-compatible.
* @remarks This will respect the {@link contentSchemaSymbol} property on data to disambiguate types - if present, only that type will be returned.
*/
export function getPossibleTypes(
allowedTypes: ReadonlySet<TreeNodeSchema>,
data: FactoryContent,
): TreeNodeSchema[] {
assert(data !== undefined, 0x889 /* undefined cannot be used as FactoryContent. */);
const type =
typeof data === "object" && data !== null
? (data as Partial<{ [contentSchemaSymbol]: string }>)[contentSchemaSymbol]
: undefined;

let best = CompatibilityLevel.None;
const possibleTypes: TreeNodeSchema[] = [];
for (const schema of allowedTypes) {
if (type !== undefined) {
if (schema.identifier === type) {
return [schema];
} else {
continue;
}
}
const handler = getTreeNodeSchemaPrivateData(schema).idempotentInitialize();
const level = handler.shallowCompatibilityTest(data);
if (level > best) {
Expand Down
144 changes: 144 additions & 0 deletions packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3544,6 +3544,150 @@ describe("treeNodeApi", () => {
}
});
});

describe("tagContentSchema", () => {
const sf = new SchemaFactory("test");
class Son extends sf.object("Son", { value: sf.number }) {}
class Daughter extends sf.object("Daughter", { value: sf.number }) {}
class Parent extends sf.object("Parent", {
child: sf.optional([Son, Daughter]),
}) {}
it("returns the same value that was passed in", () => {
const child = { value: 3 };
const son = TreeAlpha.tagContentSchema(Son, child);
assert.equal(son, child);
});

it("allows leaf types", () => {
const nullValue = null;
assert.equal(TreeAlpha.tagContentSchema(schema.null, nullValue), nullValue);
const booleanValue = true;
assert.equal(TreeAlpha.tagContentSchema(schema.boolean, booleanValue), booleanValue);
const numberValue = 3;
assert.equal(TreeAlpha.tagContentSchema(schema.number, numberValue), numberValue);
const stringValue = "hello";
assert.equal(TreeAlpha.tagContentSchema(schema.string, stringValue), stringValue);
const handleValue = new MockHandle("test");
assert.equal(TreeAlpha.tagContentSchema(schema.handle, handleValue), handleValue);
});

it("tags an object that is otherwise ambiguous", () => {
const child = { value: 3 };
// `child` could be either a Son or a Daughter, so we can't disambiguate.
assert.throws(
() => {
hydrate(Parent, { child });
},
validateUsageError(/compatible with more than one type/),
);
// If we explicitly tag it as a Daughter, it is thereafter interpreted as such.
const daughter = TreeAlpha.tagContentSchema(Daughter, child);
const parent = hydrate(Parent, { child: daughter });
assert(Tree.is(parent.child, Daughter));
});

it("tags an array that is otherwise ambiguous", () => {
class Sons extends sf.array("Sons", Son) {}
class Daughters extends sf.array("Daughters", Daughter) {}
class ArrayParent extends sf.object("Parent", {
children: sf.optional([Sons, Daughters]),
}) {}
const children = [{ value: 3 }, { value: 4 }];
assert.throws(
() => {
hydrate(ArrayParent, { children });
},
validateUsageError(/compatible with more than one type/),
);
const daughters = TreeAlpha.tagContentSchema(Daughters, children);
const parent = hydrate(ArrayParent, { children: daughters });
assert(Tree.is(parent.children, Daughters));
});

it("tags a map that is otherwise ambiguous", () => {
class SonMap extends sf.map("SonMap", Son) {}
class DaughterMap extends sf.map("DaughterMap", Daughter) {}
class MapParent extends sf.object("Parent", {
children: sf.optional([SonMap, DaughterMap]),
}) {}

const children = {
a: { value: 3 },
b: { value: 4 },
};
assert.throws(
() => {
hydrate(MapParent, { children });
},
validateUsageError(/compatible with more than one type/),
);
const daughterMap = TreeAlpha.tagContentSchema(DaughterMap, children);
const parent = hydrate(MapParent, { children: daughterMap });
assert(Tree.is(parent.children, DaughterMap));
});

it("can re-tag an object that has already been tagged", () => {
const child = { value: 3 };
const daughter = TreeAlpha.tagContentSchema(Daughter, child);
const son = TreeAlpha.tagContentSchema(Son, daughter);
const parent = hydrate(Parent, { child: son });
assert(Tree.is(parent.child, Son));
});

it("does not allow content to be interpreted as other types", () => {
const child = { value: 3 };
hydrate(Son, child);
const daughter = TreeAlpha.tagContentSchema(Daughter, child);
assert.throws(
() => hydrate(Son, daughter),
validateUsageError(/incompatible with all of the types/),
);
});

it("can be used to disambiguate deep trees", () => {
class Father extends sf.object("Father", {
child: sf.optional([Son, Daughter]),
}) {}
class Mother extends sf.object("Mother", {
child: sf.optional([Son, Daughter]),
}) {}
class GrandParent extends sf.object("GrandParent", {
parent: sf.optional([Father, Mother]),
}) {}
// Ambiguous parent and child
assert.throws(() => {
hydrate(GrandParent, {
parent: {
child: { value: 3 },
},
});
});
// Tagged parent, but ambiguous child
assert.throws(() => {
hydrate(GrandParent, {
parent: {
child: TreeAlpha.tagContentSchema(Son, { value: 3 }),
},
});
});
// Ambiguous parent, but tagged child
assert.throws(() => {
hydrate(GrandParent, {
parent: TreeAlpha.tagContentSchema(Father, {
child: { value: 3 },
}),
});
});
// Both parent and child tagged
const grandParent = hydrate(GrandParent, {
parent: TreeAlpha.tagContentSchema(Father, {
child: TreeAlpha.tagContentSchema(Son, { value: 3 }),
}),
});
assert.ok(Tree.is(grandParent.parent, Father));
assert.ok(Tree.is(grandParent.parent?.child, Son));
});
});
});

function checkoutWithInitialTree(
Expand Down
Loading
Loading