Skip to content

Commit

Permalink
feat(tree): Schema validation (#21011)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexvy86 authored May 15, 2024
1 parent 6abe291 commit b14e9fa
Show file tree
Hide file tree
Showing 24 changed files with 402 additions and 67 deletions.
15 changes: 15 additions & 0 deletions .changeset/chilly-results-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"fluid-framework": minor
"@fluidframework/tree": minor
---

Added support for optional schema validation on newly inserted content in SharedTree

When defining how to view a SharedTree, an application can now specify that new content inserted into the tree should
be subject to schema validation at the time it is inserted, so if it's not valid according to the stored schema in the
tree an error is thrown immediately.

This can be accomplished by passing an `ITreeConfigurationOptions` argument with `enableSchemaValidation` set to `true`
when creating a `TreeConfiguration` to use with the SharedTree.

Since this feature requires additional compute when inserting new content into the tree, it is not enabled by default.
12 changes: 3 additions & 9 deletions examples/benchmarks/tablebench/src/test/table.bench.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { BenchmarkType, benchmark, isInPerformanceTestingMode } from "@fluid-tools/benchmark";
import { IChannel } from "@fluidframework/datastore-definitions";
import { SharedMatrix } from "@fluidframework/matrix/internal";
import { type ITree, NodeFromSchema, SharedTree } from "@fluidframework/tree";
import { type ITree, NodeFromSchema, SharedTree, TreeConfiguration } from "@fluidframework/tree";

import { Table, generateTable } from "../index.js";

Expand Down Expand Up @@ -73,10 +73,7 @@ describe("Table", () => {
({ channel, processAllMessages } = create(SharedTree.getFactory()));
const tree = channel as unknown as ITree;

const view = tree.schematize({
schema: Table,
initialTree: () => data,
});
const view = tree.schematize(new TreeConfiguration(Table, () => data));

table = view.root;

Expand Down Expand Up @@ -189,10 +186,7 @@ describe("Table", () => {
const { channel, processAllMessages } = create(SharedTree.getFactory());
tree = channel;

tree.schematize({
schema: Table,
initialTree: () => data,
});
tree.schematize(new TreeConfiguration(Table, () => data));

processAllMessages();
summaryBytes = measureAttachmentSummary(channel);
Expand Down
9 changes: 8 additions & 1 deletion packages/dds/tree/api-report/tree.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,11 @@ export interface ITreeCheckoutFork extends ITreeCheckout, IDisposable {
rebaseOnto(view: ITreeCheckout): void;
}

// @public
export interface ITreeConfigurationOptions {
enableSchemaValidation?: boolean;
}

// @internal
export interface ITreeCursor {
readonly [CursorMarker]: true;
Expand Down Expand Up @@ -1664,6 +1669,7 @@ export interface SchemaLintConfiguration {
// @internal
export interface SchemaPolicy {
readonly fieldKinds: ReadonlyMap<FieldKindIdentifier, FieldKindData>;
readonly validateSchema: boolean;
}

// @internal
Expand Down Expand Up @@ -1844,9 +1850,10 @@ export enum TreeCompressionStrategy {

// @public
export class TreeConfiguration<TSchema extends ImplicitFieldSchema = ImplicitFieldSchema> {
constructor(schema: TSchema, initialTree: () => InsertableTreeFieldFromImplicitField<TSchema>);
constructor(schema: TSchema, initialTree: () => InsertableTreeFieldFromImplicitField<TSchema>, options?: ITreeConfigurationOptions);
// (undocumented)
readonly initialTree: () => InsertableTreeFieldFromImplicitField<TSchema>;
readonly options: Required<ITreeConfigurationOptions>;
// (undocumented)
readonly schema: TSchema;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/dds/tree/src/core/schema-stored/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ export interface SchemaPolicy {
* and will be unable to process any changes that use those FieldKinds.
*/
readonly fieldKinds: ReadonlyMap<FieldKindIdentifier, FieldKindData>;

/**
* If true, new content inserted into the tree should be validated against the stored schema.
*/
readonly validateSchema: boolean;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ import { fieldKinds } from "./defaultFieldKinds.js";
*/
export const defaultSchemaPolicy: FullSchemaPolicy = {
fieldKinds,
validateSchema: false,
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ export {
relevantRemovedRoots,
} from "./defaultEditBuilder.js";

export { SchemaValidationErrors, isNodeInSchema } from "./schemaChecker.js";

export { defaultSchemaPolicy } from "./defaultSchema.js";
2 changes: 2 additions & 0 deletions packages/dds/tree/src/feature-libraries/flex-tree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,5 @@ export {
} from "./flexTreeTypes.js";

export { assertFlexTreeEntityNotFreed } from "./lazyEntity.js";

export { getSchemaAndPolicy } from "./utilities.js";
15 changes: 14 additions & 1 deletion packages/dds/tree/src/feature-libraries/flex-tree/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import {
anchorSlot,
getDetachedFieldContainingPath,
rootField,
type SchemaAndPolicy,
} from "../../core/index.js";

import { TreeStatus } from "./flexTreeTypes.js";
import { TreeStatus, type FlexTreeEntity } from "./flexTreeTypes.js";
/**
* Checks the detached field and returns the TreeStatus based on whether or not the detached field is a root field.
* @param detachedField - the detached field you want to check.
Expand Down Expand Up @@ -72,3 +73,15 @@ export interface DetachedFieldCache {
generationNumber: number;
detachedField: DetachedField;
}

/**
* Utility function to get a {@link SchemaAndPolicy} object from a {@link FlexTreeNode} or {@link FlexTreeField}.
* @param nodeOrField - {@link FlexTreeNode} or {@link FlexTreeField} to get the schema and policy from.
* @returns A {@link SchemaAndPolicy} object with the stored schema and policy from the node or field provided.
*/
export function getSchemaAndPolicy(nodeOrField: FlexTreeEntity): SchemaAndPolicy {
return {
schema: nodeOrField.context.checkout.storedSchema,
policy: nodeOrField.context.schema.policy,
};
}
3 changes: 3 additions & 0 deletions packages/dds/tree/src/feature-libraries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ export {
fieldKindConfigurations,
intoDelta,
relevantRemovedRoots,
SchemaValidationErrors,
isNodeInSchema,
} from "./default-schema/index.js";

export {
Expand Down Expand Up @@ -275,6 +277,7 @@ export {
FlexTreeObjectNodeFieldsInner,
assertFlexTreeEntityNotFreed,
flexTreeSlot,
getSchemaAndPolicy,
} from "./flex-tree/index.js";

export { treeSchemaFromStoredSchema } from "./storedToViewSchema.js";
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 @@ -273,6 +273,7 @@ export {
ITree,
TreeNodeSchema,
TreeConfiguration,
ITreeConfigurationOptions,
TreeView,
TreeViewEvents,
SchemaFactory,
Expand Down
11 changes: 9 additions & 2 deletions packages/dds/tree/src/shared-tree/schematizingTreeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,16 @@ export class SchematizingSimpleTreeView<in out TRootSchema extends ImplicitField
public readonly config: TreeConfiguration<TRootSchema>,
public readonly nodeKeyManager: NodeKeyManager,
) {
const policy = {
...defaultSchemaPolicy,
validateSchema: config.options.enableSchemaValidation,
};
this.rootFieldSchema = normalizeFieldSchema(config.schema);
this.flexConfig = toFlexConfig(config, nodeKeyManager);
this.viewSchema = new ViewSchema(defaultSchemaPolicy, {}, this.flexConfig.schema);
this.flexConfig = toFlexConfig(config, nodeKeyManager, {
schema: checkout.storedSchema,
policy,
});
this.viewSchema = new ViewSchema(policy, {}, this.flexConfig.schema);
this.update();

this.unregisterCallbacks.add(
Expand Down
5 changes: 4 additions & 1 deletion packages/dds/tree/src/simple-tree/arrayNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
FlexTreeSequenceField,
FlexTreeTypedField,
FlexTreeUnboxField,
getSchemaAndPolicy,
} from "../feature-libraries/index.js";
import {
FactoryContent,
Expand Down Expand Up @@ -593,9 +594,10 @@ abstract class CustomArrayNodeBase<const T extends ImplicitAllowedTypes>
protected abstract get simpleSchema(): T;

#cursorFromFieldData(value: Insertable<T>): ITreeCursorSynchronous {
const sequenceField = getSequenceField(this);
const content = contextualizeInsertedArrayContent(
value as readonly (InsertableContent | IterableTreeArrayContent<InsertableContent>)[],
getSequenceField(this),
sequenceField,
);

// TODO: this is not valid since this is a value field schema, not a sequence one (which does not exist in the simple tree layer),
Expand All @@ -606,6 +608,7 @@ abstract class CustomArrayNodeBase<const T extends ImplicitAllowedTypes>
content,
simpleFieldSchema,
getFlexNode(this).context.nodeKeyManager,
getSchemaAndPolicy(sequenceField),
);
}

Expand Down
9 changes: 8 additions & 1 deletion packages/dds/tree/src/simple-tree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
* Licensed under the MIT License.
*/

export { ITree, TreeView, TreeViewEvents, TreeConfiguration, SchemaIncompatible } from "./tree.js";
export {
ITree,
TreeView,
TreeViewEvents,
TreeConfiguration,
ITreeConfigurationOptions,
SchemaIncompatible,
} from "./tree.js";
export {
TreeNodeSchema,
NodeFromSchema,
Expand Down
2 changes: 2 additions & 0 deletions packages/dds/tree/src/simple-tree/mapNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
FlexTreeTypedField,
FlexTreeUnboxField,
FlexibleFieldContent,
getSchemaAndPolicy,
} from "../feature-libraries/index.js";
import {
InsertableContent,
Expand Down Expand Up @@ -127,6 +128,7 @@ abstract class CustomMapNodeBase<const T extends ImplicitAllowedTypes> extends T
content,
classSchema.info as ImplicitAllowedTypes,
node.context.nodeKeyManager,
getSchemaAndPolicy(node),
);

node.set(key, cursor);
Expand Down
2 changes: 2 additions & 0 deletions packages/dds/tree/src/simple-tree/objectNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
FlexTreeObjectNode,
FlexTreeOptionalField,
FlexTreeRequiredField,
getSchemaAndPolicy,
NodeKeyManager,
} from "../feature-libraries/index.js";
import {
Expand Down Expand Up @@ -218,6 +219,7 @@ export function setField(
content,
simpleFieldSchema.allowedTypes,
nodeKeyManager,
getSchemaAndPolicy(field),
);
typedField.content = cursor;
break;
Expand Down
37 changes: 33 additions & 4 deletions packages/dds/tree/src/simple-tree/toFlexSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
/* eslint-disable import/no-internal-modules */
import { assert, unreachableCase } from "@fluidframework/core-utils/internal";

import { ITreeCursorSynchronous, TreeNodeSchemaIdentifier } from "../core/index.js";
import {
ITreeCursorSynchronous,
TreeNodeSchemaIdentifier,
type SchemaAndPolicy,
} from "../core/index.js";
import {
FieldKinds,
FlexAllowedTypes,
Expand Down Expand Up @@ -58,24 +62,49 @@ function cursorFromUnhydratedRoot(
schema: ImplicitFieldSchema,
tree: InsertableTreeNodeFromImplicitAllowedTypes,
nodeKeyManager: NodeKeyManager,
schemaValidationPolicy: SchemaAndPolicy | undefined = undefined,
): ITreeCursorSynchronous {
const data = extractFactoryContent(tree as InsertableContent);
const normalizedFieldSchema = normalizeFieldSchema(schema);
return (
cursorFromNodeData(data, normalizedFieldSchema.allowedTypes, nodeKeyManager) ??
fail("failed to decode tree")
cursorFromNodeData(
data,
normalizedFieldSchema.allowedTypes,
nodeKeyManager,
schemaValidationPolicy,
) ?? fail("failed to decode tree")
);
}

/**
* Generates a configuration object (schema + initial tree) for a FlexTree.
* @param config - Configuration for how to {@link ITree.schematize|schematize} a tree.
* @param nodeKeyManager - See {@link NodeKeyManager}.
* @param schemaValidationPolicy - Stored schema and policy for the tree. If the policy specifies
* `{@link SchemaPolicy.validateSchema} === true`, new content inserted into the tree will be validated using this
* object.
* @returns A configuration object for a FlexTree.
*
* @privateremarks
* I wrote these docs without a ton of context, they can probably be improved.
*/
export function toFlexConfig(
config: TreeConfiguration,
nodeKeyManager: NodeKeyManager,
schemaValidationPolicy: SchemaAndPolicy | undefined = undefined,
): TreeContent {
const unhydrated = config.initialTree();
const initialTree =
unhydrated === undefined
? undefined
: [cursorFromUnhydratedRoot(config.schema, unhydrated, nodeKeyManager)];
: [
cursorFromUnhydratedRoot(
config.schema,
unhydrated,
nodeKeyManager,
schemaValidationPolicy,
),
];
return {
schema: toFlexSchema(config.schema),
initialTree,
Expand Down
Loading

0 comments on commit b14e9fa

Please sign in to comment.