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

fix(typeEvlauator): handle rest on object splat operations #232

Merged
merged 7 commits into from
Jul 2, 2024

Conversation

sgulseth
Copy link
Member

@sgulseth sgulseth commented May 7, 2024

When splatting an object we didn't map over the rest property, if it's an object we should append the attributes. If rest is unknown we should treat the entire object as unknown.

Fixes sanity-io/sanity#6555

@sgulseth sgulseth requested review from judofyr and a team May 7, 2024 10:49
@sgulseth sgulseth force-pushed the fix/rest-object-splat branch 2 times, most recently from 8ef8b24 to 15ec7ac Compare May 7, 2024 13:21
@judofyr
Copy link
Collaborator

judofyr commented May 7, 2024

Something looks a bit sketchy.

Here we invoke the mapper multiple times:

if (node.type === 'union') {
for (const scoped of node.of) {
mapObjectSplat(scoped, scope, mapper)
}
}

But here we assume that it's only being called once per name:

mapObjectSplat(value, scope, (name, attribute) => {
attributes[name] = attribute
})

mapObjectSplat also only handles union and object and ignores unknown. It seems that if you now splat something which we can't determine it ends up being ignored? 🤔

I'm not 100% sure what the right approach here is though. Maybe more usage of mapConcrete?

@sgulseth
Copy link
Member Author

sgulseth commented May 7, 2024

yess, this should probably be a concrete type with mapConcrete, will fix. Didn't think much about fixing other bugs than the one referenced 😅

@sgulseth
Copy link
Member Author

sgulseth commented May 7, 2024

@judofyr did another pass on comparing with mapped concrete type node

@sgulseth sgulseth force-pushed the fix/rest-object-splat branch 3 times, most recently from 1558ef1 to 8992280 Compare May 14, 2024 11:36
const attributes: Record<string, ObjectAttribute> = {}

const concreteNode = mapConcrete(node, scope, (node) => node)
if (concreteNode.type === 'object') {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm. Doesn't this mean that if this returns false we end up returning {type: 'object', attributes: {}} below? What about we instead wrap all of this in mapConcrete?

return mapConcrete(node, scope, (node) => {
  if (node.type !== 'object') return {type: 'unknown'}
  const attributes: Record<string, ObjectAttribute> = {}
  // same same…
})

Copy link
Member Author

Choose a reason for hiding this comment

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

I did another pass, moved the handling of the object splats out in its own methods

@sgulseth sgulseth force-pushed the fix/rest-object-splat branch 2 times, most recently from fa78ba0 to 05e70bc Compare May 16, 2024 12:36
@sgulseth sgulseth requested a review from judofyr May 16, 2024 13:06
Comment on lines 248 to 231
if (result.type !== 'object') {
return result
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think this makes sense: If the splatted node is not an object then we return that as the result of the whole object?

In the following query:

*[_type == "author"][0] {
    _type == "author" => select(age >= 18 => {lastname}, {firstname}),
    _id,
    _type
  }

Then we now end up generating:

{
  "type": "union",
  "of": Array [
    Object {
      "type": "object",
      "attributes": Object {
        "firstname": Object {
          "type": "objectAttribute",
          "value": Object {
            "type": "string",
          },
        },
      },
    },
    Object {
      "type": "object",
      "attributes": Object {
        "lastname": Object {
          "type": "objectAttribute",
          "value": Object {
            "type": "string",
          },
        },
      },
    },
    Object {
      "type": "null",
    },
  ],
}

Because the "conditional splat" turns into a union and thus we hit this case.

Comment on lines 167 to 176
if (mapped.type != 'object') {
return mapped
}
for (const name in mapped.attributes) {
if (!mapped.attributes.hasOwnProperty(name)) {
continue
}
attributes[name] = mapped.attributes[name]
}
return {type: 'object', attributes}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This code has just basically takes a type: "object" and then makes a copy of it without rest or dereferencesTo. That doesn't look right to me.

Comment on lines 147 to 148
if (concreteRest.type !== 'object') {
return {type: 'null'}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This means that if concreteRest.type is "union" we end up returning null?

@judofyr
Copy link
Collaborator

judofyr commented Jun 3, 2024

It's a bit unclear to me what the intended behavior of handling objects with unions and inlines are at the moment. If there's union in any of ObjectSplat there's multiple approaches:

  • We can do the the "cross product": E.g. if there's 3 different object splats which all have 3 unions we end up with a top-level union of 9 different alternatives.
  • We can choose that this is just always {type: "unknown"}.
  • We can take the last union of objects, change all of them to have rest: unknown. That way we get typings for the "last" set of attributes, but not the "earlier" ones.

Maybe we can add some more fine-grained tests using overrideTypeForNode which demonstrates how it will behave in various cases?

@judofyr
Copy link
Collaborator

judofyr commented Jun 3, 2024

How about creating some tests like this (maybe turn it into a table-based one?):

const nodeWithType = (type: TypeNode) => {
  const expr: ExprNode = {type: 'Value', value: null}
  overrideTypeForNode(expr, type)
  return expr
}

t.test('objects', (t) => {
  // test 1
  typeEvaluate(
    {
      type: 'Object',
      attributes: [
        {type: 'ObjectAttributeValue', name: 'foo', value: nodeWithType({type: 'string'})},
      ],
    },
    [],
  )

  // test 2
  typeEvaluate(
    {
      type: 'Object',
      attributes: [
        {type: 'ObjectAttributeValue', name: 'foo', value: nodeWithType({type: 'string'})},
        {type: 'ObjectAttributeValue', name: 'foo', value: nodeWithType({type: 'number'})},
      ],
    },
    [],
  )

  // test 3
  typeEvaluate(
    {
      type: 'Object',
      attributes: [
        {type: 'ObjectAttributeValue', name: 'foo', value: nodeWithType({type: 'string'})},
        {type: 'ObjectSplat', value: nodeWithType({type: 'unknown'})},
      ],
    },
    [],
  )
})

@sgulseth sgulseth force-pushed the fix/rest-object-splat branch 6 times, most recently from f566358 to 7767d39 Compare June 6, 2024 09:48
@sgulseth
Copy link
Member Author

sgulseth commented Jun 6, 2024

I've addressed your feedback(100% agree), we should now be handling unions by always using mapConcrete. I also moved resolveInline into a separate function so we can be explicit about resolving inline nodes.

@sgulseth sgulseth requested a review from judofyr June 6, 2024 09:49
Comment on lines 3196 to 3960
expects: unionOf(
{
type: 'object',
attributes: {
foo: {type: 'objectAttribute', value: {type: 'boolean'}},
},
},
{
type: 'unknown',
},
),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there any value in returning {foo: boolean} | unknown instead of just unknown here?

Copy link
Member Author

Choose a reason for hiding this comment

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

We could short circuit so that if any of the returned elements are unknown we return unknown. Guess this could happen if you have a schema where a field is either an object or and array of something, not sure that's very realistic, though it could be during a migration for example.

If you have multiple attributes in the object, you could simplify the type check by just asserting one value and typescript would assume it's not the unknown object 🤔

Personally no strong opinions

},

{
name: 'Test splatting over multiple conditionals leads to a matrix of all possible combinations',
Copy link
Collaborator

Choose a reason for hiding this comment

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

So, here we have:

  • Attribute with noah
  • Conditional splat with bear | fox | deer
  • Conditional splat with deer | elk | moose
  • Conditional splat with ox | pig | chicken

Wouldn't one possible choice here be an object with {noah, beer, deer, ox}? If all the conditionals match? I can't find that in the list.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, it's way more common to have conditional splats with a plain object (e.g. _type == "foo" => {abc, def}) and not a union so it would be nice to have some tests for that as well.

@sgulseth sgulseth force-pushed the fix/rest-object-splat branch 2 times, most recently from ed0356c to 49a4266 Compare June 17, 2024 09:09
@sgulseth sgulseth force-pushed the fix/rest-object-splat branch 13 times, most recently from cb8f61d to 3c2908b Compare June 17, 2024 18:54
@sgulseth sgulseth requested a review from judofyr June 17, 2024 18:58
@sgulseth
Copy link
Member Author

Pushed my latest changes, there's optimizations to be done in the object handler itself(probably way too many loops 😅), but if it's correct I can give optimizations another passs

Copy link
Collaborator

@judofyr judofyr left a comment

Choose a reason for hiding this comment

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

I've mainly focused on the tests and they all look sensible to me.

(I haven't looked much at the implementation. Let me know if there's anything particular there you'd like me to review. Otherwise I'm pretty happy doing a "test-first" approach for this code anyway.)

test/typeEvaluateObjects.test.ts Outdated Show resolved Hide resolved
test/typeEvaluateObjects.test.ts Outdated Show resolved Hide resolved
@sgulseth sgulseth force-pushed the fix/rest-object-splat branch 3 times, most recently from 9007ba9 to 0fbbcbe Compare July 2, 2024 12:25
@sgulseth sgulseth merged commit 1a5106d into main Jul 2, 2024
9 checks passed
@sgulseth sgulseth deleted the fix/rest-object-splat branch July 2, 2024 12:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Typegen not reading => @ correctly.
2 participants