-
Notifications
You must be signed in to change notification settings - Fork 537
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
Changes from all commits
9e2e30d
6cdd756
1f7e1fa
30f1d4d
43981e8
72d8c26
fa176ce
cb30d7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. | ||
|
||
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. |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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[] { | ||||||
|
@@ -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}`); | ||||||
} | ||||||
|
||||||
|
@@ -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 | ||||||
|
@@ -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. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"
Or maybe we can use There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||||||
} | ||||||
|
||||||
/** | ||||||
|
@@ -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 | ||||||
|
@@ -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 { | ||||||
|
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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 anumber[]
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.There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.