Skip to content
This repository has been archived by the owner on Aug 8, 2023. It is now read-only.

[ios, macos] NSPredicate expression filters. #11587

Merged
merged 16 commits into from
Apr 16, 2018

Conversation

fabian-guerra
Copy link
Contributor

@fabian-guerra fabian-guerra commented Apr 3, 2018

Fixes #11567

  • Add support for predicate expression filters.
  • Add NSPredicate<->ExpressionFilter converter.
  • Update tests.
  • Update documentation.

@fabian-guerra fabian-guerra added iOS Mapbox Maps SDK for iOS macOS Mapbox Maps SDK for macOS release blocker Blocks the next final release labels Apr 3, 2018
@fabian-guerra fabian-guerra self-assigned this Apr 3, 2018
@fabian-guerra fabian-guerra requested a review from 1ec5 April 3, 2018 22:20
case NSLessThanPredicateOperatorType:
case NSLessThanOrEqualToPredicateOperatorType:
case NSInPredicateOperatorType:
case NSContainsPredicateOperatorType:
case NSBetweenPredicateOperatorType: {
Copy link
Contributor

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];
Copy link
Contributor

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);
Copy link
Contributor

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];
Copy link
Contributor

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.

Copy link
Contributor Author

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

Copy link
Contributor

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.

Copy link
Contributor

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?

Copy link
Contributor Author

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

Copy link
Contributor

@1ec5 1ec5 Apr 13, 2018

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.

@fabian-guerra fabian-guerra force-pushed the fabian-expression-filters-11567 branch 2 times, most recently from ba9115c to f36376c Compare April 11, 2018 18:12
#import "NSPredicate+MGLAdditions.h"
#import "NSExpression+MGLPrivateAdditions.h"

@implementation NSComparisonPredicate (MGLAdditions)

- (mbgl::style::Filter)mgl_filter {
Copy link
Contributor

Choose a reason for hiding this comment

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

We probably shouldn’t drop this conversion code until #11610 lands.

/cc @lucaswoj

Copy link
Contributor

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::Filters 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: {
Copy link
Contributor

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.

Copy link
Contributor Author

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 {
Copy link
Contributor

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)]);
Copy link
Contributor

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().

Copy link
Contributor Author

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.

Copy link
Contributor

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.)

Copy link
Contributor Author

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.

Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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]];
Copy link
Contributor

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 {
Copy link
Contributor

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.

Copy link
Contributor Author

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"];
Copy link
Contributor

@1ec5 1ec5 Apr 12, 2018

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?

Copy link
Contributor

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

Copy link
Contributor

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"]]];
Copy link
Contributor

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"]];
Copy link
Contributor

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.

@fabian-guerra fabian-guerra force-pushed the fabian-expression-filters-11567 branch 2 times, most recently from fe68238 to 44f8507 Compare April 13, 2018 20:22
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 {
Copy link
Contributor

@1ec5 1ec5 Apr 13, 2018

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)

Copy link
Contributor Author

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).

Copy link
Contributor

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;
Copy link
Contributor

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];
Copy link
Contributor

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"]) {
Copy link
Contributor

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?

Copy link
Contributor Author

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

@fabian-guerra fabian-guerra force-pushed the fabian-expression-filters-11567 branch from 0f31a2a to 232bfd4 Compare April 13, 2018 23:30
Copy link
Contributor

@1ec5 1ec5 left a 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")
Copy link
Contributor

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.

Copy link
Contributor

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')`
Copy link
Contributor

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`
Copy link
Contributor

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`


Copy link
Contributor

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.

Copy link
Contributor

@1ec5 1ec5 left a 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.

Copy link
Contributor

@jmkiley jmkiley left a comment

Choose a reason for hiding this comment

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

Thank you!

@fabian-guerra fabian-guerra merged commit 62dd097 into release-boba Apr 16, 2018
@fabian-guerra fabian-guerra deleted the fabian-expression-filters-11567 branch April 16, 2018 20:52
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
iOS Mapbox Maps SDK for iOS macOS Mapbox Maps SDK for macOS release blocker Blocks the next final release
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants