Skip to content
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

tree: Allow array from map and map from array #22036

Merged
merged 8 commits into from
Jul 26, 2024
31 changes: 31 additions & 0 deletions .changeset/moody-toys-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@fluidframework/tree": minor
"fluid-framework": minor
---

Allow constructing ArrayNodes from Maps and MapNodes from arrays when unambiguous.

Since the types for ArrayNodes and MapNodes indicate they can be constructed from iterables,
it should work, even if those iterables are themselves arrays or maps.
To avoid this being a breaking change, a priority system was introduced.
ArrayNodes will only be implicitly constructable from JavaScript Map objects in contexts where no MapNodes are allowed.
Similarly MapNodes will only be implicitly constructable from JavaScript Array objects in contexts where no ArrayNodes are allowed.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we improve upon this even more in the future? You could have e.g. Map<string, string> | Array<string>, and we would be able to tell the difference between the two for an input like [string] (array) or [[string, string]] (map), so long as the array isn't empty. Or, is that too subtle of an edge case that it will confuse users, and it's better to simply keep the dividing line as it is? I'm inclined to think that it's too subtle, but I'm curious what you think.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. You can't tell as [string, string][] from a number[] in the case where they are empty, so in general we can't do that general improvement always, but we can do some cases of it (ex: all non-empty cases) in the future, and doing so will be a non-breaking change since it would convert cases which were ambiguous to working, and cases which had type errors on the children to type errors on the parent. Both those changes will be allowed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the kind of change you mention (being more picky, causing error higher up the tree and less ambiguity) is generally safe, where the change I made here (allowing more options) is prone to causing new ambiguity issues which is what needed the extra care and priority thing

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good observation, that makes sense. Thank you.


In practice, the main case in which this is likely to matter is when implicitly constructing a map node. If you provide an array of key value pairs, this now works instead of erroring, as long as no ArrayNode is valid at that location in the tree.

```typescript
class MyMapNode extends schemaFactory.map("x", schemaFactory.number) {}
class Root extends schemaFactory.object("root", { data: MyMapNode }) {}
// This now works (before it compiled, but error at runtime):
const fromArray = new Root({ data: [["x", 5]] });
```

Prior versions used to have to do:
```typescript
new Root({ data: new MyMapNode([["x", 5]]) });
```
or:
```typescript
new Root({ data: new Map([["x", 5]]) });
```
Both of these options still work: strictly more cases are allowed with this change.
8 changes: 1 addition & 7 deletions packages/dds/tree/src/simple-tree/arrayNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,13 +932,7 @@ export function arraySchema<
): MapTreeNode {
return getOrCreateMapTreeNode(
flexSchema,
mapTreeFromNodeData(
// Ensure input iterable is not an map. See TODO in shallowCompatibilityTest.
Array.from(
input as Iterable<InsertableTreeNodeFromImplicitAllowedTypes<T>>,
) as object,
this as unknown as ImplicitAllowedTypes,
),
mapTreeFromNodeData(input as object, this as unknown as ImplicitAllowedTypes),
);
}

Expand Down
3 changes: 1 addition & 2 deletions packages/dds/tree/src/simple-tree/mapNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,7 @@ export function mapSchema<
return getOrCreateMapTreeNode(
flexSchema,
mapTreeFromNodeData(
// Ensure input iterable is not an array. See TODO in shallowCompatibilityTest.
new Map(input as Iterable<readonly [string, InsertableContent]>),
input as Iterable<readonly [string, InsertableContent]>,
this as unknown as ImplicitAllowedTypes,
),
);
Expand Down
83 changes: 62 additions & 21 deletions packages/dds/tree/src/simple-tree/toMapTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ function mapValueWithFallbacks(
* be thrown if the tree does not conform to the schema. If undefined, no validation against the stored schema is done.
*/
function arrayToMapTreeFields(
data: readonly InsertableContent[],
data: Iterable<InsertableContent>,
allowedTypes: ReadonlySet<TreeNodeSchema>,
schemaValidationPolicy: SchemaAndPolicy | undefined,
): ExclusiveMapTree[] {
Expand Down Expand Up @@ -338,15 +338,15 @@ function arrayToMapTreeFields(

/**
* Transforms data under an Array schema.
* @param data - The tree data to be transformed. Must be an array.
* @param data - The tree data to be transformed. Must be an iterable.
* @param schema - The schema associated with the value.
* @param schemaValidationPolicy - The stored schema and policy to be used for validation, if the policy says schema
* validation should happen. If it does, the input tree will be validated against this schema + policy, and an error will
* be thrown if the tree does not conform to the schema. If undefined, no validation against the stored schema is done.
*/
function arrayToMapTree(data: InsertableContent, schema: TreeNodeSchema): ExclusiveMapTree {
assert(schema.kind === NodeKind.Array, 0x922 /* Expected an array schema. */);
if (!isReadonlyArray(data)) {
if (!(typeof data === "object" && data !== null && Symbol.iterator in data)) {
throw new UsageError(`Input data is incompatible with Array schema: ${data}`);
}

Expand All @@ -367,7 +367,7 @@ function arrayToMapTree(data: InsertableContent, schema: TreeNodeSchema): Exclus

/**
* Transforms data under a Map schema.
* @param data - The tree data to be transformed. Must be a TypeScript Map.
* @param data - The tree data to be transformed. Must be an iterable.
* @param schema - The schema associated with the value.
* @param schemaValidationPolicy - The stored schema and policy to be used for validation, if the policy says schema
* validation should happen. If it does, the input tree will be validated against this schema + policy, and an error will
Expand Down Expand Up @@ -497,13 +497,41 @@ export function getPossibleTypes(
allowedTypes: ReadonlySet<TreeNodeSchema>,
data: ContextuallyTypedNodeData,
): TreeNodeSchema[] {
let best = CompatibilityLevel.None;
const possibleTypes: TreeNodeSchema[] = [];
for (const schema of allowedTypes) {
if (shallowCompatibilityTest(schema, data)) {
const level = shallowCompatibilityTest(schema, data);
if (level > best) {
possibleTypes.length = 0;
best = level;
}
if (best === level) {
possibleTypes.push(schema);
}
}
return possibleTypes;
return best === CompatibilityLevel.None ? [] : possibleTypes;
}

/**
* Indicates a compatibility level for inferring a schema to apply to insertable data.
* @remarks
* Only the highest compatibility options are used.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Only the highest compatibility options are used.
* Only the highest quality compatibility options are used. Here at `getPossibleTypes` we pride ourselves on bringing the freshest options direct to you for a fraction of the cogs of our competitors. We stand by our enums - if you are dissatisfied* in any way, your `allowedTypes` and `data` will be returned to you free of charge and unmutated. [* Satisfaction only guaranteed after TS 4.9](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think your explanation is inaccurate. The quality here is relative, so when low is the highest quality available, we still use it, which could invalidate such a guarantee.

* This approach allows adding new possible matching at a new lower compatibility level as a non breaking change,
* since that way they can't make a case that was compatible before ambiguous now.
*/
enum CompatibilityLevel {
/**
* Not compatible. Constructor typing indicates incompatibility.
*/
None = 0,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "lower compatibility levels" that might be added in the future would be "between" None and Low, right? Should we adjust the numeric enum values to reflect that, so there is room to add the future values? E.g.

None = 0, Lower = 253, Low = 254, Normal = 255

Or maybe we can use 0.5 for Lower, lol.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can always change these values in the future (this is not a public const enum: its neither public nor const), not to mention there are a lot of numbers between 0 and 1.

/**
* Additional compatibility cases added in Fluid Framework 2.2.
*/
Low = 1,
/**
* Compatible in Fluid Framework 2.0.
*/
Normal = 2,
}

/**
Expand All @@ -516,46 +544,59 @@ export function getPossibleTypes(
function shallowCompatibilityTest(
schema: TreeNodeSchema,
data: ContextuallyTypedNodeData,
): boolean {
): CompatibilityLevel {
assert(
data !== undefined,
0x889 /* undefined cannot be used as contextually typed data. Use ContextuallyTypedFieldData. */,
);

if (isTreeValue(data)) {
return allowsValue(schema, data);
return allowsValue(schema, data) ? CompatibilityLevel.Normal : CompatibilityLevel.None;
}
if (schema.kind === NodeKind.Leaf) {
return false;
return CompatibilityLevel.None;
}

// TODO:
// current typing (of schema based constructors and thus implicit node construction)
// Typing (of schema based constructors and thus implicit node construction)
// allows iterables for constructing maps and arrays.
// Some current users of this API may have unions of maps and arrays,
// Some users of this API may have unions of maps and arrays,
// and rely on Arrays ending up as array nodes and maps as Map nodes,
// despite both being iterable and thus compatible with both.
// In the future, a better solution could be a priority based system where an array would be parsed as an array when unioned with a map,
// This uses a priority based system where an array would be parsed as an array when unioned with a map,
// but if in a map only context, could still be used as a map.
// Then this method would return a quality of fit, not just a boolean.
// For now, special case map and array before checking iterable to avoid regressing the union of map and array case.

if (data instanceof Map) {
return schema.kind === NodeKind.Map;
switch (schema.kind) {
case NodeKind.Map:
return CompatibilityLevel.Normal;
case NodeKind.Array:
// Maps are iterable, so type checking does allow constructing an ArrayNode from a map if the array's type is an array that includes the key and value types of the map.
return CompatibilityLevel.Low;
default:
return CompatibilityLevel.None;
}
}

if (isReadonlyArray(data)) {
return schema.kind === NodeKind.Array;
switch (schema.kind) {
case NodeKind.Array:
return CompatibilityLevel.Normal;
case NodeKind.Map:
// Arrays are iterable, so type checking does allow constructing an array from a MapNode from an if the array's type is key values pairs for the map.
return CompatibilityLevel.Low;
default:
return CompatibilityLevel.None;
}
}

const mapOrArray = schema.kind === NodeKind.Array || schema.kind === NodeKind.Map;

if (Symbol.iterator in data) {
return mapOrArray;
return mapOrArray ? CompatibilityLevel.Normal : CompatibilityLevel.None;
}

if (mapOrArray) {
return false;
return CompatibilityLevel.None;
}

// Assume record-like object
Expand All @@ -571,11 +612,11 @@ function shallowCompatibilityTest(
// If the schema has a required key which is not present in the input object, reject it.
for (const [fieldKey, fieldSchema] of schema.fields) {
if (data[fieldKey] === undefined && fieldSchema.requiresValue) {
return false;
return CompatibilityLevel.None;
}
}

return true;
return CompatibilityLevel.Normal;
}

function allowsValue(schema: TreeNodeSchema, value: TreeValue): boolean {
Expand Down
44 changes: 44 additions & 0 deletions packages/dds/tree/src/test/simple-tree/arrayNode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,4 +835,48 @@ describe("ArrayNode", () => {
assert.deepEqual(result2, [1, 2, 3]);
});
});

it("explicit construction", () => {
class Schema extends schemaFactory.array(
"x",
schemaFactory.array([schemaFactory.number, schemaFactory.string]),
) {}
const data = [["x", 5]] as const;
const json = JSON.stringify(data);
const fromArray = new Schema(data);
assert.equal(JSON.stringify(fromArray), json);
const fromMap = new Schema(new Map(data));
assert.equal(JSON.stringify(fromMap), json);
const fromIterable = new Schema(new Map(data).entries());
assert.equal(JSON.stringify(fromIterable), json);
});

describe("implicit construction", () => {
it("fromArray", () => {
class Schema extends schemaFactory.array("x", schemaFactory.number) {}
class Root extends schemaFactory.object("root", { data: Schema }) {}
const fromArray = new Root({ data: [5] });
assert.deepEqual([...fromArray.data], [5]);
});
it("fromMap", () => {
class Schema extends schemaFactory.array(
"x",
schemaFactory.array([schemaFactory.number, schemaFactory.string]),
) {}
class Root extends schemaFactory.object("root", { data: Schema }) {}

const data = [["x", 5]] as const;
const json = JSON.stringify(data);

const fromMap = new Root({ data: new Map(data) });
assert.equal(JSON.stringify(fromMap.data), json);
});
it("fromIterable", () => {
class Schema extends schemaFactory.array("x", schemaFactory.number) {}
class Root extends schemaFactory.object("root", { data: Schema }) {}
const fromArray = new Root({ data: [5] });
const fromIterable = new Root({ data: new Set([5]) });
assert.deepEqual([...fromIterable.data], [5]);
});
});
});
3 changes: 1 addition & 2 deletions packages/dds/tree/src/test/simple-tree/mapNode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ describe("MapNode", () => {
class Schema extends schemaFactory.map("x", schemaFactory.number) {}
class Root extends schemaFactory.object("root", { data: Schema }) {}
const data = [["x", 5]] as const;
// See TODO in shallowCompatibilityTest for how to enable this case.
it.skip("fromArray", () => {
it("fromArray", () => {
const fromArray = new Root({ data });
assert.deepEqual([...fromArray.data], data);
});
Expand Down
10 changes: 10 additions & 0 deletions packages/dds/tree/src/test/simple-tree/toMapTree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1447,6 +1447,16 @@ describe("toMapTree", () => {
[mapSchema, arraySchema],
);
});

it("array vs map low priority matching", () => {
const f = new SchemaFactory("test");
const arraySchema = f.array([f.null]);
const mapSchema = f.map([f.null]);
// Array makes map
assert.deepEqual(getPossibleTypes(new Set([mapSchema]), []), [mapSchema]);
// Map makes array
assert.deepEqual(getPossibleTypes(new Set([arraySchema]), new Map()), [arraySchema]);
});
});

describe("addDefaultsToMapTree", () => {
Expand Down
Loading