-
Notifications
You must be signed in to change notification settings - Fork 1.3k
[ios, macos] NSPredicate expression filters. #11587
Conversation
case NSLessThanPredicateOperatorType: | ||
case NSLessThanOrEqualToPredicateOperatorType: | ||
case NSInPredicateOperatorType: | ||
case NSContainsPredicateOperatorType: | ||
case NSBetweenPredicateOperatorType: { |
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.
We don’t really need a switch statement here anymore. -mgl_jsonExpressionObject
can be responsible for raising an exception when the operator type is unsupported.
} else if ([op isEqualToString:@"number"] || | ||
[op isEqualToString:@"string"] || | ||
[op isEqualToString:@"boolean"]) { | ||
return [NSExpression mgl_expressionWithJSONObject:argumentObjects.firstObject]; |
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.
I’m not sure it’s a good idea to make the conversion from JSON to NSExpression lossy like this. If these calls to number
etc. are failing the round-tripping tests, feel free to modify the tests to start out with these calls so that they pass without needing to remove the type assertions.
{ | ||
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"x == YES"]; | ||
NSArray *jsonExpression = @[@"==", @[@"get", @"x"], @YES]; | ||
XCTAssertEqualObjects(predicate.mgl_jsonExpressionObject, jsonExpression); |
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.
Also test that reversing the order of the operands produces the same kind of expression.
} | ||
|
||
if (type) { | ||
return @[type, expressionElement.mgl_jsonExpressionObject]; |
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.
It shouldn’t be the job of the platform-specific code to insert type assertions based on the data type of the constant value.
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.
I agree with that. But we will have to deal with this type assertion until mapbox/mapbox-gl-js#6459 is done. Expression filter comparisons relay on the data type.
/cc @anandthakker
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.
Unfortunately, the code in this PR is only capable of inserting type assertions for the most basic expressions, but not for something more complex like CAST(ele, 'NSNumber') * 0.3048 > 1000
. To handle the whole gamut of expressions that could require type assertions, we’d have to implement static type analysis in Objective-C.
Since it’s probably too late for mapbox/mapbox-gl-js#6459 to make it into the v4.0.0 release, we’ll have to settle for expecting the developer to include type assertions (or, more likely, CAST()
) in their predicates. As long as the type checking in this PR isn’t incorrect, I suppose we can keep it, but it would complicate the developer’s mental model of when they’d need to add type assertions themselves.
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.
mapbox/mapbox-gl-js#6459 would not take very long to implement, once we have consensus on the design. Is this something we should try to expedite to get into v4.0.0?
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.
@anandthakker if it's possible to get it done for 4.0.0 would be good. Otherwise I think we can live with this for now and in a patch release remove it.
/cc @1ec5
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.
Getting mapbox/mapbox-gl-js#6459 in would be nice, but I’m wary of further delaying the v4.0.0 for it. Either way, the SDK code should not elide type assertions during conversion to NSPredicate. I guess we can live with introducing type assertions during conversion to JSON, with the understanding that it’s already quite limited and will be removed shortly.
ba9115c
to
f36376c
Compare
#import "NSPredicate+MGLAdditions.h" | ||
#import "NSExpression+MGLPrivateAdditions.h" | ||
|
||
@implementation NSComparisonPredicate (MGLAdditions) | ||
|
||
- (mbgl::style::Filter)mgl_filter { |
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.
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.
My mistake: we only need to keep FilterEvaluator
around, since that’s responsible for converting mbgl::Filter
s to NSPredicates as part of the predicate
property’s getter. -mgl_filter
, meanwhile, is part of the setter, which is already dead code, so we can remove it.
case NSLessThanPredicateOperatorType: | ||
case NSLessThanOrEqualToPredicateOperatorType: | ||
case NSGreaterThanPredicateOperatorType: | ||
case NSGreaterThanOrEqualToPredicateOperatorType: { |
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.
NSEqualToPredicateOperatorType
and NSNotEqualToPredicateOperatorType
as well.
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.
This condition applies only for this operators since are the only ones required to pass the comparator type.
} | ||
return nil; | ||
} | ||
|
||
- (id)mgl_expressionWithExpressionType:(NSExpression *)expressionElement fallbackExpression:(NSExpression *)fallbackExpression { |
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.
It’s unclear to me why the first parameter is labeled expressionType
and named expressionElement
, and the name fallbackExpression
implies that this expression is substituted for expressionElement
if the latter is missing. Finally, this method returns a JSON object rather than an NSExpression.
If I understand the purpose of this method, a clearer name would be -mgl_typeCheckedExpressionMatchingExpression:
. Instead of expressionElement
, use self
; instead of fallbackExpression
, use otherExpression
. Instead of returning a JSON expression object, return an NSExpression that calls MGL_FUNCTION(… 'boolean/number/string')
.
@@ -259,28 +271,33 @@ + (instancetype)mgl_predicateWithJSONObject:(id)object { | |||
NSString *op = objects.firstObject; | |||
|
|||
if ([op isEqualToString:@"=="]) { | |||
NSArray *subexpressions = MGLSubexpressionsWithJSONObjects([objects subarrayWithRange:NSMakeRange(1, objects.count - 1)]); | |||
NSArray *sanitizedObjects = MGLSanitizeJSONObjects([objects subarrayWithRange:NSMakeRange(1, objects.count - 1)]); |
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.
Per #11587 (comment), revert these changes and remove MGLSanitizeJSONObjects()
.
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.
I removed the code you mentioned in #11587 (comment), but this code is required for comparison predicates to work as filters. MGLSanitizeJSONObjects()
removes the data types before converting them.
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.
Hmm. I think a problem here is that this is unconditionally removing all "number"
, "string"
, "boolean"
assertions. Removing the ones that were automatically inferred is fine, because they can be inferred again if the expression is sent back into mbgl to be parsed. But it's possible to have explicitly-added assertions that would not be automatically inferred if they were stripped. For example, ["==", ["number", ["id"]], ["get", "x"]]
is an expression that specifically asserts that a feature's id
is a number and compares it to property with key "x"
. If the "number"
wrapper were stripped here, the resulting expression would fail to parse. (And even if we relaxed the typing requirements so that it did parse, its behavior would be different than that of the original.)
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.
This applies only to >, >=, <, <=
this type of evaluation is not possible using NSExpression
directly.
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.
Ah, okay. Some expressions might still fail to roundtrip -- e.g., [">", ["string", ["get", "x"]], ["string", ["get", "y"]]]
would result in [">", ["get", "x"], ["get", "y"]]
-- but I suppose that's a known/acceptable issue?
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.
@anandthakker @1ec5 seems like the best solution is require that the developers add CAST
to filter predicates. Something like:
[NSPredicate predicateWithFormat:@"CAST(x, 'NSNumber') < 5"];
This way we avoid all the problems mentioned above.
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.
The code here converts from JSON to NSPredicate. As long as an expression in JSON already has type assertions, those type assertions should be reflected in the NSPredicate. The fact that this prevents literal round-tripping of some predicates is unfortunate but not a big deal compared to potentially preventing an expression from working correctly.
@@ -394,28 +394,28 @@ - (void)testArithmeticExpressionObject { | |||
{ | |||
NSExpression *expression = [NSExpression expressionForFunction:@"ceiling:" arguments:@[MGLConstantExpression(@1.5)]]; | |||
NSArray *jsonTruncation = @[@"-", @1.5, @[@"%", @1.5, @1]]; | |||
NSArray *jsonExpression = @[@"+", jsonTruncation, @[@"case", @[@">", @[@"%", @1.5, @1], @0], @1, @0]]; | |||
NSArray *jsonExpression = @[@"+", jsonTruncation, @[@"case", @[@">", @[@"number", @[@"%", @1.5, @1]], @[@"number", @0]], @1, @0]]; |
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.
#11653 significantly simplified these conversions from NSExpression to JSON. It’s no longer necessary to insert these type assertions.
@@ -23,558 +23,404 @@ @interface MGLPredicateTests : XCTestCase | |||
|
|||
@implementation MGLPredicateTests | |||
|
|||
- (void)testFilterization { |
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.
Restore these tests until #11610 lands.
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.
Filter test were not added per #11587 (comment).
}, | ||
}; | ||
MGLAssertEqualFilters(actual, expected); | ||
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"a != nil"]; |
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.
This used to be converted into a NotHasFilter
. Ideally, we’d convert it to ["!", ["has", ["get", "a"]]]
. Are we sure that ["!=", ["get", "a"], null]
has the desired effect?
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.
["==", ["get", "a"], null]
should be equivalent to ["!", ["has", "a"]]
, because get
returns null
for a missing key
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.
That said, the latter is probably a more direct translation..
}; | ||
XCTAssertEqualObjects([NSPredicate mgl_predicateWithFilter:filter], [NSPredicate predicateWithFormat:@"NOT(a == 'b' OR c == 'd')"]); | ||
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT %@ CONTAINS a", @[@"b", @"c"]]; | ||
NSArray *jsonExpression = @[@"!", @[@"has", @[@"literal", @[@"b", @"c"]], @[@"get", @"a"]]]; |
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.
#11632 will correct this to use a match
expression.
}, | ||
}; | ||
XCTAssertEqualObjects([NSPredicate mgl_predicateWithFilter:filter], [NSPredicate predicateWithFormat:@"NOT(a == 'b' OR c == 'd')"]); | ||
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT %@ CONTAINS a", @[@"b", @"c"]]; |
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.
#11632 will also test using an aggregate expression instead of a constant array value.
fe68238
to
44f8507
Compare
Infers the expressionObject type if it can not then tries to infer the type using fallbackExpression. | ||
ExpressionFilters of type >, >=, <, <= requires that each compared value provides its type. | ||
*/ | ||
- (id)mgl_typedJSONWithExpressionObject:(NSExpression *)expressionObject fallbackExpression:(NSExpression *)fallbackExpression { |
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.
In general, any manipulation we do of expressions at the SDK level should be done in terms of NSPredicate or NSExpression APIs instead of raw Foundation types. That way, we aren’t as tightly coupled to the JSON expression syntax all over the place.
I think this method should have the following signature:
- (NSExpression *)mgl_typeCheckedExpressionMatchingExpression:(NSExpression *)otherExpression;
The following two comments explain how. The main thing is using self
, since this is an instance method, though I do think avoiding JSON inside this method would be preferable.
/ref #11587 (comment)
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.
That method is generic. I was passing both sides of the comparison expression. So calling self didn't make sense.
I removed this code since it's purpose to aid the developers in the migration is not working as intended and is causing more problems per mi comment #11587 (comment).
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.
For future reference, it would’ve been possible to call, e.g., [leftExpression mgl_typeCheckedExpressionMatchingExpression:rightExpression]
, in which case self
inside the method would’ve been equivalent to leftExpression
at the call site.
ExpressionFilters of type >, >=, <, <= requires that each compared value provides its type. | ||
*/ | ||
- (id)mgl_typedJSONWithExpressionObject:(NSExpression *)expressionObject fallbackExpression:(NSExpression *)fallbackExpression { | ||
NSExpression *expression = expressionObject.expressionType == NSConstantValueExpressionType ? expressionObject : fallbackExpression; |
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.
Replace expressionObject
with self
and fallbackExpression
with otherExpression
.
} | ||
|
||
if (type) { | ||
return @[type, expressionObject.mgl_jsonExpressionObject]; |
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.
If self
isn’t already a function expression that casts or type-asserts, then wrap self
in a new MGL_FUNCTION
expression and return that expression.
@@ -551,7 +551,7 @@ + (instancetype)expressionWithMGLJSONObject:(id)object { | |||
return [NSExpression expressionForAggregate:MGLSubexpressionsWithJSONObjects(argumentObjects.firstObject)]; | |||
} | |||
return [NSExpression expressionWithMGLJSONObject:argumentObjects.firstObject]; | |||
} else if ([op isEqualToString:@"to-boolean"]) { | |||
} else if ([op isEqualToString:@"to-boolean"] || [op isEqualToString:@"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.
boolValue
was only ever meant to coerce a value to a Boolean, not assert that it is a Boolean. Previously, to-boolean
would be translated to MGL_FUNCTION('to-boolean', …)
. Is there any potential for data loss this way?
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.
This was not meant to make it here. Removed
0f31a2a
to
232bfd4
Compare
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.
This PR is almost ready to go, but the “Predicates and Expressions” guide needs to be updated to remove the requirement that key paths appear on the left side of any comparison and also emphasize that CAST()
should be used to coerce certain key paths to numbers for inequality comparisons.
@@ -47,7 +47,7 @@ MGL_EXPORT | |||
```swift | |||
let layer = MGLLineStyleLayer(identifier: "contour", source: terrain) | |||
layer.sourceLayerIdentifier = "contours" | |||
layer.predicate = NSPredicate(format: "(index == 5 || index == 10) && ele >= 1500.0") | |||
layer.predicate = NSPredicate(format: "(index == 5 || index == 10) && CAST(ele, 'NSNumber') >= 1500.0") |
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.
This illustrates the fact that many existing uses of predicates in v3.x will need to be modified slightly when migrating to v4.0.0. On balance, I think this is necessary until mapbox/mapbox-gl-js#6459, but it does mean we’ll need to be clear about this requirement in the documentation, particularly the function-to-expression migration guide and the “Predicates and Expressions” guide.
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.
Also, the iOS and macOS changelogs should have a blurb noting that:
- Predicate format strings can contain arithmetic and calls to built-in NSExpression functions.
- Key paths may need to be cast to NSString or NSNumber in some cases.
@@ -19,6 +19,17 @@ based on the feature’s attributes. Use the `MGLVectorStyleLayer.predicate` | |||
property to include only the features in the source layer that satisfy a | |||
condition that you define. | |||
|
|||
When a predicate is used for filtering vector data the key should be cast | |||
explicitly into the key's type. A number based key will become: `CAST(key, 'NSNumber')` |
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.
Filtering vector data is the subject of this section, so it’s a bit redundant to say so.
`NSGreaterThanOrEqualToPredicateOperatorType` | `CAST(key, 'valueType') >= value`<br />`CAST(key, 'valueType') => value` | ||
`NSLessThanOrEqualToPredicateOperatorType` | `CAST(key, 'valueType') <= value`<br />`CAST(key, 'valueType') =< value` | ||
`NSGreaterThanPredicateOperatorType` | `CAST(key, 'valueType') > value` | ||
`NSLessThanPredicateOperatorType` | `CAST(key, 'valueType') < value` |
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.
This table is redundant to the table below it, which introduces these operators. Instead, add a paragraph below that table mentioning that key
may need to be cast to a number or string using the CAST()
operator and the type NSNumber
or NSString
. (Mention these types explicitly, because Swift developers may be inclined to use Float
or String
instead.)
`NSGreaterThanPredicateOperatorType` | `CAST(key, 'valueType') > value` | ||
`NSLessThanPredicateOperatorType` | `CAST(key, 'valueType') < value` | ||
|
||
|
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.
Also reword the paragraph below that starts with “ The predicate's left-hand expression must be…”, because feature attributes or these special attributes don’t need to be on the left side anymore.
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 to go as soon as the documentation feedback above is addressed.
e0f4395
to
59665b0
Compare
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.
Thank you!
Fixes #11567