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(typeEvaluator): fix in-operator union handling #256

Closed
wants to merge 4 commits into from

Conversation

sgulseth
Copy link
Member

Two changes is involved in this pr:
* This pr builds on top of #253 which fixes in-operator on union types
* Removes resolveCondition so that we can reuse types that are walked when we are resolving filters.

@sgulseth sgulseth force-pushed the refactor/remove-resolve-condition branch from 8e5ce9d to 080d612 Compare August 18, 2024 20:54
* This method should _only_ be used if you need to handle unknown types, ie when resolving two sides of an and node, and we don't want to abort if one side is unknown.
* In most cases, you should use `mapConcrete` instead.
**/
export function mapUnion(
Copy link
Member Author

@sgulseth sgulseth Aug 18, 2024

Choose a reason for hiding this comment

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

A bit annoying to need this, but the reason is that when resolving "and" conditions like _id == $id && _type == "foo" we don't want to short circuit the mapper when walking _id == $id(resolves to unknown because parameter is unknown).

Alternatively we can use mapUnion in handleOpCallNode instead, and there do a unknown check so it at least returns a boolean.

function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode {
  $trace('opcall.node %O', node)
  const lhs = walk({node: node.left, scope})
  const rhs = walk({node: node.right, scope})
  return mapUnion(lhs, scope, (left) =>
    // eslint-disable-next-line complexity, max-statements
    mapUnion(rhs, scope, (right) => {
      $trace('opcall.node.concrete "%s" %O', node.op, {left, right})
      if (left.type === 'unknown' || right.type === 'unknown') {
        return {type: 'boolean'}
      }

Then we would resolve the correct type for a projection like:

{
"foo": $myParameter == 1
}

Which currently resolves to unknown

Copy link
Collaborator

Choose a reason for hiding this comment

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

_id == $id(resolves to unknown because parameter is unknown)

Just a quick comment (before reading the rest of the problem): == (and !=) are one of the few operators which actually always return true/false (null is not possible). So regardless of what the RHS is then this will be boolean.

Copy link
Collaborator

Choose a reason for hiding this comment

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

And similarly: Comparisons operators are always boolean | null. We should always be able to fallback to that.

Copy link
Member Author

Choose a reason for hiding this comment

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

Change this into function handleOpCallNode handling unknowns with mapUnion, and ==/!= will now always return a boolean, while the other operators short circuits to unknown if an unknown is given.

@@ -838,7 +889,7 @@ t.test('values in projection', (t) => {
type: 'objectAttribute',
value: {
type: 'boolean',
value: undefined,
value: false,
Copy link
Member Author

Choose a reason for hiding this comment

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

neither foo nor bar exists on the document, so this will always be false

@@ -1313,7 +1364,7 @@ t.test('with select, not guaranteed & with fallback', (t) => {
const query = `*[_type == "author" || _type == "post"] {
_type,
"something": select(
_id > 5 => _id,
_id == "5" => _id,
Copy link
Member Author

Choose a reason for hiding this comment

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

_id is a string, so fixed the comparison

"unknownParent": ^._id,
"unknownParent2": ^.^.^.^.^.^.^.^._id,
"andWithAttriute": !false && !someAttriute,
"andWithAttribute": !false && !someAttribute,
Copy link
Member Author

@sgulseth sgulseth Aug 18, 2024

Choose a reason for hiding this comment

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

a typo, but added the actual attribute so that the test makes sense

@sgulseth sgulseth force-pushed the refactor/remove-resolve-condition branch from 080d612 to aa37dab Compare August 19, 2024 06:20
@sgulseth sgulseth requested a review from judofyr August 19, 2024 06:24
@sgulseth sgulseth force-pushed the refactor/remove-resolve-condition branch 3 times, most recently from e86ddb0 to b5bb564 Compare August 19, 2024 07:00
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.

Having evaluate type tests for And and Or will probably uncover potential bugs around the handling.

t.test(`And`, async (t) => {
  subtestBinary({
    t,
    variants1: trivialVariant,
    variants2: trivialVariant,
    build: (left, right) => ({type: 'And', left, right})
  })
})

t.test(`Or`, async (t) => {
  subtestBinary({
    t,
    variants1: trivialVariant,
    variants2: trivialVariant,
    build: (left, right) => ({type: 'Or', left, right})
  })
})

* This method should _only_ be used if you need to handle unknown types, ie when resolving two sides of an and node, and we don't want to abort if one side is unknown.
* In most cases, you should use `mapConcrete` instead.
**/
export function mapUnion(
Copy link
Collaborator

Choose a reason for hiding this comment

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

_id == $id(resolves to unknown because parameter is unknown)

Just a quick comment (before reading the rest of the problem): == (and !=) are one of the few operators which actually always return true/false (null is not possible). So regardless of what the RHS is then this will be boolean.

src/typeEvaluator/typeEvaluate.ts Outdated Show resolved Hide resolved
src/typeEvaluator/typeEvaluate.ts Outdated Show resolved Hide resolved
* This method should _only_ be used if you need to handle unknown types, ie when resolving two sides of an and node, and we don't want to abort if one side is unknown.
* In most cases, you should use `mapConcrete` instead.
**/
export function mapUnion(
Copy link
Collaborator

Choose a reason for hiding this comment

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

And similarly: Comparisons operators are always boolean | null. We should always be able to fallback to that.

@sgulseth sgulseth force-pushed the refactor/remove-resolve-condition branch 4 times, most recently from 50c1024 to 17de1e4 Compare August 21, 2024 06:25
@sgulseth sgulseth requested a review from judofyr August 21, 2024 06:26
Comment on lines +546 to +556
// Special case for global::path, since it can be used with in operator, but the type returned otherwise is a string
if (isFuncCall(node.right, 'global::path')) {
return {type: 'boolean'}
}
Copy link
Member Author

Choose a reason for hiding this comment

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

A bit hacky, but just needed to short circuit the operator if used with path.

Another approach, that might come in a follow-up PR is to put the scope into filterMode, and we can then use that to determine what typenode to return in the function. This could also then be used to rewrite the scope when using literal comparisons or the defined method

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.

One proper request: The handling of null + true seems broken in And/Or. I think this would have been covered by adding a typeEvaluateCompare test where we add union({type: 'null'}, …). Currently we only union by unknown. It's a bit tricky to add this test without exploding all of the variants though. We might need more fine-grained per-op variations…

One suggestion which can be done separately from this PR: Don't distinguish between mapConcrete and mapUnion, but always use one (I propose keeping the name mapConcrete) which also requires you to handle unknown.

src/typeEvaluator/typeEvaluate.ts Outdated Show resolved Hide resolved
Comment on lines 1066 to 1078
const leftValue = booleanValue(lhs)
const rightValue = booleanValue(rhs)
if (leftValue.canBeTrue || rightValue.canBeTrue) {
if (leftValue.canBeFalse || rightValue.canBeFalse) {
if (leftValue.canBeNull || rightValue.canBeNull) {
return nullUnion({type: 'boolean'})
}

return {type: 'boolean'}
}

return {type: 'boolean', value: true}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Isn't this wrong if both values are [canBeTrue, canBeNull] and [canBeTrue, canBeNull]? 🤔

In which case the actual type should be nullIUnion({type: 'boolean'}), but here it would be detected as {type: 'boolean', value: true}?

I think potentially this can be easier expressed by defining the logic on BooleanInterpretation:

function booleanOr(left: BooleanInterpretation, right: BooleanInterpretation): BooleanInterpretation {
  // One side is true => return `true`
  if (left.canBeTrue && !left.canBeFalse && !left.canBeNull) return left
  if (right.canBeTrue && !right.canBeFalse && !right.canBeNull) return right

  // One side is false => return the other side
  if (!left.canBeTrue && left.canBeFalse && !left.canBeNull) return right
  if (!right.canBeTrue && right.canBeFalse && !right.canBeNull) return left

  // Guaranteed to be boolean at least:
  if (!left.canBeNull && !right.canBeNull) return {canBeTrue: true, canBeFalse: true, canBeNull: false}

  // Fallback to the most permissive:
  return {canBeTrue: true, canBeFalse: true, canBeNull: true}
}

I think this covers the main cases.

And then and becomes quite symmetric:

function booleanAnd(left: BooleanInterpretation, right: BooleanInterpretation): BooleanInterpretation {
  // One side is true => return that side
  if (left.canBeTrue && !left.canBeFalse && !left.canBeNull) return right
  if (right.canBeTrue && !right.canBeFalse && !right.canBeNull) return left

  // One side is false => return `false`
  if (!left.canBeTrue && left.canBeFalse && !left.canBeNull) return left
  if (!right.canBeTrue && right.canBeFalse && !right.canBeNull) return right

  // Guaranteed to be boolean at least:
  if (!left.canBeNull && !right.canBeNull) return {canBeTrue: true, canBeFalse: true, canBeNull: false}

  // Fallback to the most permissive:
  return {canBeTrue: true, canBeFalse: true, canBeNull: true}
}

src/typeEvaluator/typeHelpers.ts Outdated Show resolved Hide resolved
src/typeEvaluator/typeEvaluate.ts Outdated Show resolved Hide resolved
@sgulseth sgulseth force-pushed the refactor/remove-resolve-condition branch 10 times, most recently from bc3c41e to 3da81f0 Compare August 23, 2024 08:08
@sgulseth sgulseth force-pushed the refactor/remove-resolve-condition branch from 3da81f0 to a4bb1e3 Compare August 23, 2024 08:11
@sgulseth sgulseth force-pushed the refactor/remove-resolve-condition branch from a4bb1e3 to 3eb2f48 Compare August 23, 2024 08:15
@sgulseth
Copy link
Member Author

Fixed in #260

@sgulseth sgulseth closed this Aug 23, 2024
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.

2 participants