Skip to content

Commit 25e74f9

Browse files
tree: Allow array from map and map from array (#22036)
## Description Allow constructing ArrayNodes from Maps and MapNodes from arrays when unambiguous. Also removes some no longer needed shallow copies in the map and array constructors . See changeset for details.
1 parent 06681c3 commit 25e74f9

File tree

7 files changed

+150
-32
lines changed

7 files changed

+150
-32
lines changed

.changeset/moody-toys-dance.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@fluidframework/tree": minor
3+
"fluid-framework": minor
4+
---
5+
6+
Allow constructing ArrayNodes from Maps and MapNodes from arrays when unambiguous.
7+
8+
Since the types for ArrayNodes and MapNodes indicate they can be constructed from iterables,
9+
it should work, even if those iterables are themselves arrays or maps.
10+
To avoid this being a breaking change, a priority system was introduced.
11+
ArrayNodes will only be implicitly constructable from JavaScript Map objects in contexts where no MapNodes are allowed.
12+
Similarly MapNodes will only be implicitly constructable from JavaScript Array objects in contexts where no ArrayNodes are allowed.
13+
14+
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.
15+
16+
```typescript
17+
class MyMapNode extends schemaFactory.map("x", schemaFactory.number) {}
18+
class Root extends schemaFactory.object("root", { data: MyMapNode }) {}
19+
// This now works (before it compiled, but error at runtime):
20+
const fromArray = new Root({ data: [["x", 5]] });
21+
```
22+
23+
Prior versions used to have to do:
24+
```typescript
25+
new Root({ data: new MyMapNode([["x", 5]]) });
26+
```
27+
or:
28+
```typescript
29+
new Root({ data: new Map([["x", 5]]) });
30+
```
31+
Both of these options still work: strictly more cases are allowed with this change.

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -932,13 +932,7 @@ export function arraySchema<
932932
): MapTreeNode {
933933
return getOrCreateMapTreeNode(
934934
flexSchema,
935-
mapTreeFromNodeData(
936-
// Ensure input iterable is not an map. See TODO in shallowCompatibilityTest.
937-
Array.from(
938-
input as Iterable<InsertableTreeNodeFromImplicitAllowedTypes<T>>,
939-
) as object,
940-
this as unknown as ImplicitAllowedTypes,
941-
),
935+
mapTreeFromNodeData(input as object, this as unknown as ImplicitAllowedTypes),
942936
);
943937
}
944938

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,7 @@ export function mapSchema<
238238
return getOrCreateMapTreeNode(
239239
flexSchema,
240240
mapTreeFromNodeData(
241-
// Ensure input iterable is not an array. See TODO in shallowCompatibilityTest.
242-
new Map(input as Iterable<readonly [string, InsertableContent]>),
241+
input as Iterable<readonly [string, InsertableContent]>,
243242
this as unknown as ImplicitAllowedTypes,
244243
),
245244
);

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

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ function mapValueWithFallbacks(
309309
* be thrown if the tree does not conform to the schema. If undefined, no validation against the stored schema is done.
310310
*/
311311
function arrayToMapTreeFields(
312-
data: readonly InsertableContent[],
312+
data: Iterable<InsertableContent>,
313313
allowedTypes: ReadonlySet<TreeNodeSchema>,
314314
schemaValidationPolicy: SchemaAndPolicy | undefined,
315315
): ExclusiveMapTree[] {
@@ -338,15 +338,15 @@ function arrayToMapTreeFields(
338338

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

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

368368
/**
369369
* Transforms data under a Map schema.
370-
* @param data - The tree data to be transformed. Must be a TypeScript Map.
370+
* @param data - The tree data to be transformed. Must be an iterable.
371371
* @param schema - The schema associated with the value.
372372
* @param schemaValidationPolicy - The stored schema and policy to be used for validation, if the policy says schema
373373
* validation should happen. If it does, the input tree will be validated against this schema + policy, and an error will
@@ -497,13 +497,41 @@ export function getPossibleTypes(
497497
allowedTypes: ReadonlySet<TreeNodeSchema>,
498498
data: ContextuallyTypedNodeData,
499499
): TreeNodeSchema[] {
500+
let best = CompatibilityLevel.None;
500501
const possibleTypes: TreeNodeSchema[] = [];
501502
for (const schema of allowedTypes) {
502-
if (shallowCompatibilityTest(schema, data)) {
503+
const level = shallowCompatibilityTest(schema, data);
504+
if (level > best) {
505+
possibleTypes.length = 0;
506+
best = level;
507+
}
508+
if (best === level) {
503509
possibleTypes.push(schema);
504510
}
505511
}
506-
return possibleTypes;
512+
return best === CompatibilityLevel.None ? [] : possibleTypes;
513+
}
514+
515+
/**
516+
* Indicates a compatibility level for inferring a schema to apply to insertable data.
517+
* @remarks
518+
* Only the highest compatibility options are used.
519+
* This approach allows adding new possible matching at a new lower compatibility level as a non breaking change,
520+
* since that way they can't make a case that was compatible before ambiguous now.
521+
*/
522+
enum CompatibilityLevel {
523+
/**
524+
* Not compatible. Constructor typing indicates incompatibility.
525+
*/
526+
None = 0,
527+
/**
528+
* Additional compatibility cases added in Fluid Framework 2.2.
529+
*/
530+
Low = 1,
531+
/**
532+
* Compatible in Fluid Framework 2.0.
533+
*/
534+
Normal = 2,
507535
}
508536

509537
/**
@@ -516,46 +544,59 @@ export function getPossibleTypes(
516544
function shallowCompatibilityTest(
517545
schema: TreeNodeSchema,
518546
data: ContextuallyTypedNodeData,
519-
): boolean {
547+
): CompatibilityLevel {
520548
assert(
521549
data !== undefined,
522550
0x889 /* undefined cannot be used as contextually typed data. Use ContextuallyTypedFieldData. */,
523551
);
524552

525553
if (isTreeValue(data)) {
526-
return allowsValue(schema, data);
554+
return allowsValue(schema, data) ? CompatibilityLevel.Normal : CompatibilityLevel.None;
527555
}
528556
if (schema.kind === NodeKind.Leaf) {
529-
return false;
557+
return CompatibilityLevel.None;
530558
}
531559

532-
// TODO:
533-
// current typing (of schema based constructors and thus implicit node construction)
560+
// Typing (of schema based constructors and thus implicit node construction)
534561
// allows iterables for constructing maps and arrays.
535-
// Some current users of this API may have unions of maps and arrays,
562+
// Some users of this API may have unions of maps and arrays,
536563
// and rely on Arrays ending up as array nodes and maps as Map nodes,
537564
// despite both being iterable and thus compatible with both.
538-
// 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,
565+
// This uses a priority based system where an array would be parsed as an array when unioned with a map,
539566
// but if in a map only context, could still be used as a map.
540-
// Then this method would return a quality of fit, not just a boolean.
541-
// For now, special case map and array before checking iterable to avoid regressing the union of map and array case.
542567

543568
if (data instanceof Map) {
544-
return schema.kind === NodeKind.Map;
569+
switch (schema.kind) {
570+
case NodeKind.Map:
571+
return CompatibilityLevel.Normal;
572+
case NodeKind.Array:
573+
// 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.
574+
return CompatibilityLevel.Low;
575+
default:
576+
return CompatibilityLevel.None;
577+
}
545578
}
546579

547580
if (isReadonlyArray(data)) {
548-
return schema.kind === NodeKind.Array;
581+
switch (schema.kind) {
582+
case NodeKind.Array:
583+
return CompatibilityLevel.Normal;
584+
case NodeKind.Map:
585+
// 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.
586+
return CompatibilityLevel.Low;
587+
default:
588+
return CompatibilityLevel.None;
589+
}
549590
}
550591

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

553594
if (Symbol.iterator in data) {
554-
return mapOrArray;
595+
return mapOrArray ? CompatibilityLevel.Normal : CompatibilityLevel.None;
555596
}
556597

557598
if (mapOrArray) {
558-
return false;
599+
return CompatibilityLevel.None;
559600
}
560601

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

578-
return true;
619+
return CompatibilityLevel.Normal;
579620
}
580621

581622
function allowsValue(schema: TreeNodeSchema, value: TreeValue): boolean {

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,4 +835,48 @@ describe("ArrayNode", () => {
835835
assert.deepEqual(result2, [1, 2, 3]);
836836
});
837837
});
838+
839+
it("explicit construction", () => {
840+
class Schema extends schemaFactory.array(
841+
"x",
842+
schemaFactory.array([schemaFactory.number, schemaFactory.string]),
843+
) {}
844+
const data = [["x", 5]] as const;
845+
const json = JSON.stringify(data);
846+
const fromArray = new Schema(data);
847+
assert.equal(JSON.stringify(fromArray), json);
848+
const fromMap = new Schema(new Map(data));
849+
assert.equal(JSON.stringify(fromMap), json);
850+
const fromIterable = new Schema(new Map(data).entries());
851+
assert.equal(JSON.stringify(fromIterable), json);
852+
});
853+
854+
describe("implicit construction", () => {
855+
it("fromArray", () => {
856+
class Schema extends schemaFactory.array("x", schemaFactory.number) {}
857+
class Root extends schemaFactory.object("root", { data: Schema }) {}
858+
const fromArray = new Root({ data: [5] });
859+
assert.deepEqual([...fromArray.data], [5]);
860+
});
861+
it("fromMap", () => {
862+
class Schema extends schemaFactory.array(
863+
"x",
864+
schemaFactory.array([schemaFactory.number, schemaFactory.string]),
865+
) {}
866+
class Root extends schemaFactory.object("root", { data: Schema }) {}
867+
868+
const data = [["x", 5]] as const;
869+
const json = JSON.stringify(data);
870+
871+
const fromMap = new Root({ data: new Map(data) });
872+
assert.equal(JSON.stringify(fromMap.data), json);
873+
});
874+
it("fromIterable", () => {
875+
class Schema extends schemaFactory.array("x", schemaFactory.number) {}
876+
class Root extends schemaFactory.object("root", { data: Schema }) {}
877+
const fromArray = new Root({ data: [5] });
878+
const fromIterable = new Root({ data: new Set([5]) });
879+
assert.deepEqual([...fromIterable.data], [5]);
880+
});
881+
});
838882
});

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,7 @@ describe("MapNode", () => {
7979
class Schema extends schemaFactory.map("x", schemaFactory.number) {}
8080
class Root extends schemaFactory.object("root", { data: Schema }) {}
8181
const data = [["x", 5]] as const;
82-
// See TODO in shallowCompatibilityTest for how to enable this case.
83-
it.skip("fromArray", () => {
82+
it("fromArray", () => {
8483
const fromArray = new Root({ data });
8584
assert.deepEqual([...fromArray.data], data);
8685
});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1447,6 +1447,16 @@ describe("toMapTree", () => {
14471447
[mapSchema, arraySchema],
14481448
);
14491449
});
1450+
1451+
it("array vs map low priority matching", () => {
1452+
const f = new SchemaFactory("test");
1453+
const arraySchema = f.array([f.null]);
1454+
const mapSchema = f.map([f.null]);
1455+
// Array makes map
1456+
assert.deepEqual(getPossibleTypes(new Set([mapSchema]), []), [mapSchema]);
1457+
// Map makes array
1458+
assert.deepEqual(getPossibleTypes(new Set([arraySchema]), new Map()), [arraySchema]);
1459+
});
14501460
});
14511461

14521462
describe("addDefaultsToMapTree", () => {

0 commit comments

Comments
 (0)