Skip to content

Commit c6e40ba

Browse files
committed
Add ensureSchema
1 parent bc3a1a8 commit c6e40ba

File tree

9 files changed

+243
-2
lines changed

9 files changed

+243
-2
lines changed

packages/dds/tree/api-report/tree.alpha.api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,9 @@ export interface SchemaStaticsAlpha {
965965
readonly typesRecursive: <const T extends readonly Unenforced<AnnotatedAllowedType | LazyItem<TreeNodeSchema>>[]>(t: T, metadata?: AllowedTypesMetadata) => AllowedTypesFullFromMixedUnsafe<T>;
966966
}
967967

968+
// @alpha
969+
export const schemaSymbol: unique symbol;
970+
968971
// @alpha @sealed
969972
export class SchemaUpgrade {
970973
// (undocumented)
@@ -1348,6 +1351,7 @@ export interface TreeAlpha {
13481351
child(node: TreeNode, key: string | number): TreeNode | TreeLeafValue | undefined;
13491352
children(node: TreeNode): Iterable<[propertyKey: string | number, child: TreeNode | TreeLeafValue]>;
13501353
create<const TSchema extends ImplicitFieldSchema | UnsafeUnknownSchema>(schema: UnsafeUnknownSchema extends TSchema ? ImplicitFieldSchema : TSchema & ImplicitFieldSchema, data: InsertableField<TSchema>): Unhydrated<TSchema extends ImplicitFieldSchema ? TreeFieldFromImplicitField<TSchema> : TreeNode | TreeLeafValue | undefined>;
1354+
ensureSchema<TSchema extends TreeNodeSchema, TContent extends InsertableField<TSchema>>(schema: TSchema, content: TContent): TContent;
13511355
exportCompressed(tree: TreeNode | TreeLeafValue, options: {
13521356
idCompressor?: IIdCompressor;
13531357
} & Pick<CodecWriteOptions, "minVersionForCollab">): JsonCompatible<IFluidHandle>;

packages/dds/tree/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export {
123123
type WithType,
124124
type NodeChangedData,
125125
type SchemaUpgrade,
126+
schemaSymbol,
126127
// Types not really intended for public use, but used in links.
127128
// Can not be moved to internalTypes since doing so causes app code to throw errors like:
128129
// 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"

packages/dds/tree/src/shared-tree/treeAlpha.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ import {
5858
importConcise,
5959
exportConcise,
6060
borrowCursorFromTreeNodeOrValue,
61+
schemaSymbol,
62+
type TreeNodeSchema,
6163
} from "../simple-tree/index.js";
6264
import { brand, extractFromOpaque, type JsonCompatible } from "../util/index.js";
6365
import {
@@ -519,6 +521,31 @@ export interface TreeAlpha {
519521
onInvalidation: () => void,
520522
trackDuring: () => TResult,
521523
): ObservationResults<TResult>;
524+
525+
/**
526+
* Ensures that the provided content will be interpreted as the given schema when inserting into the tree.
527+
* @returns `content`, for convenience.
528+
* @remarks
529+
* If applicable, this will tag the given content with a {@link schemaSymbol | special property} that indicates its intended schema.
530+
* The `content` will be interpreted as the given `schema` when later inserted into the tree.
531+
* This is particularly useful when the content's schema cannot be inferred from its structure alone because it is compatible with multiple schemas.
532+
* @example
533+
* ```typescript
534+
* const sf = new SchemaFactory("example");
535+
* class Dog extends sf.object("Dog", { name: sf.string() }) {}
536+
* class Cat extends sf.object("Cat", { name: sf.string() }) {}
537+
* class Root extends sf.object("Root", { pet: [Dog, Cat] }) {}
538+
* // ...
539+
* const pet = { name: "Max" };
540+
* view.root.pet = pet; // Error: ambiguous schema - is it a Dog or a Cat?
541+
* TreeAlpha.ensureSchema(Dog, pet); // Tags `pet` as a Dog.
542+
* view.root.pet = pet; // No error - it's a Dog.
543+
* ```
544+
*/
545+
ensureSchema<TSchema extends TreeNodeSchema, TContent extends InsertableField<TSchema>>(
546+
schema: TSchema,
547+
content: TContent,
548+
): TContent;
522549
}
523550

524551
/**
@@ -1003,6 +1030,21 @@ export const TreeAlpha: TreeAlpha = {
10031030
}
10041031
return result;
10051032
},
1033+
1034+
ensureSchema<TSchema extends TreeNodeSchema, TNode extends InsertableField<TSchema>>(
1035+
schema: TSchema,
1036+
node: TNode,
1037+
): TNode {
1038+
if (typeof node === "object" && node !== null) {
1039+
Reflect.defineProperty(node, schemaSymbol, {
1040+
configurable: false,
1041+
enumerable: false,
1042+
writable: true,
1043+
value: schema.identifier,
1044+
});
1045+
}
1046+
return node;
1047+
},
10061048
};
10071049

10081050
/**

packages/dds/tree/src/simple-tree/core/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ export {
1616
SimpleContextSlot,
1717
withBufferedTreeEvents,
1818
} from "./treeNodeKernel.js";
19-
export { type WithType, typeNameSymbol, typeSchemaSymbol } from "./withType.js";
19+
export {
20+
type WithType,
21+
typeNameSymbol,
22+
typeSchemaSymbol,
23+
schemaSymbol,
24+
} from "./withType.js";
2025
export {
2126
type Unhydrated,
2227
type InternalTreeNode,

packages/dds/tree/src/simple-tree/core/withType.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

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

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

45+
/**
46+
* The intended type of insertable content that is to become a {@link TreeNode}.
47+
* @remarks **Note:** Whenever possible, use the type-safe {@link (TreeAlpha:interface).ensureSchema} function rather than manually employing this symbol.
48+
*
49+
* If a property with this symbol key is present on an object that is inserted into the tree,
50+
* the tree will use the schema identifier specified by the value of this property when creating the node.
51+
* This is particularly useful for specifying the intended schema of untyped content when it would otherwise be ambiguous.
52+
* @example
53+
* ```typescript
54+
* const sf = new SchemaFactory("example");
55+
* class Dog extends sf.object("Dog", { name: sf.string() }) {}
56+
* class Cat extends sf.object("Cat", { name: sf.string() }) {}
57+
* class Root extends sf.object("Root", { pet: [Dog, Cat] }) {}
58+
* // ...
59+
* view.root.pet = { name: "Max" }; // Error: ambiguous schema - is it a Dog or a Cat?
60+
* view.root.pet = { name: "Max", [schemaSymbol]: "example.Dog" }; // No error - it's a Dog.
61+
* ```
62+
* @alpha
63+
*/
64+
export const schemaSymbol: unique symbol = Symbol("SharedTree Schema");
65+
4266
/**
4367
* Adds a type symbol to a type for stronger typing.
4468
*

packages/dds/tree/src/simple-tree/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
export {
77
typeNameSymbol,
88
typeSchemaSymbol,
9+
schemaSymbol,
910
type WithType,
1011
type TreeNodeSchema,
1112
type AnnotatedAllowedType,

packages/dds/tree/src/simple-tree/unhydratedFlexTreeFromInsertable.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
isTreeNode,
1818
type TreeNode,
1919
type TreeNodeSchema,
20+
schemaSymbol,
2021
type Unhydrated,
2122
UnhydratedFlexTreeNode,
2223
} from "./core/index.js";
@@ -125,16 +126,24 @@ For class-based schema, this can be done by replacing an expression like "{foo:
125126

126127
/**
127128
* Returns all types for which the data is schema-compatible.
129+
* @remarks This will respect the {@link schemaSymbol} property on data to disambiguate types - if present, only that type will be returned.
128130
*/
129131
export function getPossibleTypes(
130132
allowedTypes: ReadonlySet<TreeNodeSchema>,
131133
data: FactoryContent,
132134
): TreeNodeSchema[] {
133135
assert(data !== undefined, 0x889 /* undefined cannot be used as FactoryContent. */);
136+
const type =
137+
typeof data === "object" && data !== null
138+
? (data as Partial<{ [schemaSymbol]: string }>)[schemaSymbol]
139+
: undefined;
134140

135141
let best = CompatibilityLevel.None;
136142
const possibleTypes: TreeNodeSchema[] = [];
137143
for (const schema of allowedTypes) {
144+
if (type !== undefined && schema.identifier === type) {
145+
return [schema];
146+
}
138147
const handler = getTreeNodeSchemaPrivateData(schema).idempotentInitialize();
139148
const level = handler.shallowCompatibilityTest(data);
140149
if (level > best) {

packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3544,6 +3544,138 @@ describe("treeNodeApi", () => {
35443544
}
35453545
});
35463546
});
3547+
3548+
describe("ensureType", () => {
3549+
const sf = new SchemaFactory("test");
3550+
class Son extends sf.object("Son", { value: sf.number }) {}
3551+
class Daughter extends sf.object("Daughter", { value: sf.number }) {}
3552+
class Parent extends sf.object("Parent", {
3553+
child: sf.optional([Son, Daughter]),
3554+
}) {}
3555+
it("returns the same value that was passed in", () => {
3556+
const child = { value: 3 };
3557+
const son = TreeAlpha.ensureSchema(Son, child);
3558+
assert.equal(son, child);
3559+
});
3560+
3561+
it("allows leaf types", () => {
3562+
const nullValue = null;
3563+
assert.equal(TreeAlpha.ensureSchema(schema.null, nullValue), nullValue);
3564+
const booleanValue = true;
3565+
assert.equal(TreeAlpha.ensureSchema(schema.boolean, booleanValue), booleanValue);
3566+
const numberValue = 3;
3567+
assert.equal(TreeAlpha.ensureSchema(schema.number, numberValue), numberValue);
3568+
const stringValue = "hello";
3569+
assert.equal(TreeAlpha.ensureSchema(schema.string, stringValue), stringValue);
3570+
});
3571+
3572+
it("tags an object that is otherwise ambiguous", () => {
3573+
const child = { value: 3 };
3574+
// `child` could be either a Son or a Daughter, so we can't disambiguate.
3575+
assert.throws(
3576+
() => {
3577+
hydrate(Parent, { child });
3578+
},
3579+
validateUsageError(/compatible with more than one type/),
3580+
);
3581+
// If we explicitly tag it as a Daughter, it is thereafter interpreted as such.
3582+
const daughter = TreeAlpha.ensureSchema(Daughter, child);
3583+
const parent = hydrate(Parent, { child: daughter });
3584+
assert(Tree.is(parent.child, Daughter));
3585+
});
3586+
3587+
it("tags an array that is otherwise ambiguous", () => {
3588+
class Sons extends sf.array("Sons", Son) {}
3589+
class Daughters extends sf.array("Daughters", Daughter) {}
3590+
class ArrayParent extends sf.object("Parent", {
3591+
children: sf.optional([Sons, Daughters]),
3592+
}) {}
3593+
const children = [{ value: 3 }, { value: 4 }];
3594+
assert.throws(
3595+
() => {
3596+
hydrate(ArrayParent, { children });
3597+
},
3598+
validateUsageError(/compatible with more than one type/),
3599+
);
3600+
const daughters = TreeAlpha.ensureSchema(Daughters, children);
3601+
const parent = hydrate(ArrayParent, { children: daughters });
3602+
assert(Tree.is(parent.children, Daughters));
3603+
});
3604+
3605+
it("tags a map that is otherwise ambiguous", () => {
3606+
class SonMap extends sf.map("SonMap", Son) {}
3607+
class DaughterMap extends sf.map("DaughterMap", Daughter) {}
3608+
class MapParent extends sf.object("Parent", {
3609+
children: sf.optional([SonMap, DaughterMap]),
3610+
}) {}
3611+
3612+
const children = {
3613+
a: { value: 3 },
3614+
b: { value: 4 },
3615+
};
3616+
assert.throws(
3617+
() => {
3618+
hydrate(MapParent, { children });
3619+
},
3620+
validateUsageError(/compatible with more than one type/),
3621+
);
3622+
const daughterMap = TreeAlpha.ensureSchema(DaughterMap, children);
3623+
const parent = hydrate(MapParent, { children: daughterMap });
3624+
assert(Tree.is(parent.children, DaughterMap));
3625+
});
3626+
3627+
it("can re-tag an object that has already been tagged", () => {
3628+
const child = { value: 3 };
3629+
const daughter = TreeAlpha.ensureSchema(Daughter, child);
3630+
const son = TreeAlpha.ensureSchema(Son, daughter);
3631+
const parent = hydrate(Parent, { child: son });
3632+
assert(Tree.is(parent.child, Son));
3633+
});
3634+
3635+
it("can be used to disambiguate deep trees", () => {
3636+
class Father extends sf.object("Father", {
3637+
child: sf.optional([Son, Daughter]),
3638+
}) {}
3639+
class Mother extends sf.object("Mother", {
3640+
child: sf.optional([Son, Daughter]),
3641+
}) {}
3642+
class GrandParent extends sf.object("GrandParent", {
3643+
parent: sf.optional([Father, Mother]),
3644+
}) {}
3645+
// Ambiguous parent and child
3646+
assert.throws(() => {
3647+
hydrate(GrandParent, {
3648+
parent: {
3649+
child: { value: 3 },
3650+
},
3651+
});
3652+
});
3653+
// Tagged parent, but ambiguous child
3654+
assert.throws(() => {
3655+
hydrate(GrandParent, {
3656+
parent: {
3657+
child: TreeAlpha.ensureSchema(Son, { value: 3 }),
3658+
},
3659+
});
3660+
});
3661+
// Ambiguous parent, but tagged child
3662+
assert.throws(() => {
3663+
hydrate(GrandParent, {
3664+
parent: TreeAlpha.ensureSchema(Father, {
3665+
child: { value: 3 },
3666+
}),
3667+
});
3668+
});
3669+
// Both parent and child tagged
3670+
const grandParent = hydrate(GrandParent, {
3671+
parent: TreeAlpha.ensureSchema(Father, {
3672+
child: TreeAlpha.ensureSchema(Son, { value: 3 }),
3673+
}),
3674+
});
3675+
assert.ok(Tree.is(grandParent.parent, Father));
3676+
assert.ok(Tree.is(grandParent.parent?.child, Son));
3677+
});
3678+
});
35473679
});
35483680

35493681
function checkoutWithInitialTree(

packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import {
4343
} from "../../feature-libraries/index.js";
4444
import { validateUsageError } from "../utils.js";
4545
// eslint-disable-next-line import/no-internal-modules
46-
import { UnhydratedFlexTreeNode } from "../../simple-tree/core/index.js";
46+
import { schemaSymbol, UnhydratedFlexTreeNode } from "../../simple-tree/core/index.js";
4747
// eslint-disable-next-line import/no-internal-modules
4848
import { getUnhydratedContext } from "../../simple-tree/createContext.js";
4949
// eslint-disable-next-line import/no-internal-modules
@@ -1459,6 +1459,29 @@ describe("unhydratedFlexTreeFromInsertable", () => {
14591459
[Optional],
14601460
);
14611461
});
1462+
1463+
it("with type symbol", () => {
1464+
const f = new SchemaFactory("test");
1465+
class A extends f.object("A", {
1466+
value: f.string,
1467+
}) {}
1468+
class B extends f.object("B", {
1469+
value: f.string,
1470+
}) {}
1471+
// Type symbol specified when there is only one valid option
1472+
const content = { [schemaSymbol]: "test.A", value: "hello" };
1473+
assert.deepEqual(getPossibleTypes(new Set([A]), content), [A]);
1474+
1475+
// Type symbol specified when there are multiple valid options
1476+
const ambiguousContent = { [schemaSymbol]: "test.B", value: "hello" };
1477+
// Only B, even though A is also valid based on properties
1478+
assert.deepEqual(getPossibleTypes(new Set([A, B]), ambiguousContent), [B]);
1479+
1480+
// Type symbol specified that does not match any options
1481+
const invalidContent = { [schemaSymbol]: "test.C", value: "hello" };
1482+
// Should fall back to A, as if no type symbol was provided
1483+
assert.deepEqual(getPossibleTypes(new Set([A]), invalidContent), [A]);
1484+
});
14621485
});
14631486

14641487
describe("defaults", () => {

0 commit comments

Comments
 (0)