Creating an advanced NSPredicateEditorRowTemplate

In a recent post I covered how to make a simple NSPredicateEditorRowTemplate. In this post we’re going to go a bit deeper.

The Setup

In our last example, we did some simple date comparisons, where we were comparing an NSTimeInterval against a property that represented a duration. For example, if we had an array of Song objects, then we could use that row template to construct a predicate to find all songs that were longer than 5 minutes (duration > 300). But what if our objects don’t have a duration property? What if they have a date property, such as the date this song was added? Well, we can use the built-in initializers of NSPredicateEditorRowTemplate to create a template where the right expression type is NSDateAttributeType. This is all well and good, but it has a fundamental flaw: It can only compare against static dates.

In other words, I can easily create a row template for predicates of the form <a date> <is, is not, is before, is on or before, is after, is on or after> <a specific date>. But what if I want to do something like <a date> <is in the last, is in the next> <user-entered number> <seconds, minutes, hours, days, weeks>? Is there a way to do relative date comparison? The answer is “yes”, but this is going to take a lot of explanation. So buckle up, and let’s dive right in!

The Problems

There are two major problems to overcome when doing relative date comparison and integrating that with NSPredicateEditorRowTemplate:

  1. NSPredicateEditor compacts left expressions into a unique list. Where a left expression supports multiple operators, those are also uniqued. In other words, you can’t do both myKeyPath = <a value form a textfield> and myKeyPath = <a value from a popup> in the same NSPredicateEditor. It doesn’t matter if these are defined in different row templates. NSPredicateEditor will still find both of them, and won’t know what to display when the user chooses myKeyPath from the first popup and = from the second popup. What should it display? A textfield? A popupmenu? We care about this because if we want to support both myDate <is after> <a specific date> and myDate <is in the last> <number> <time unit>, these both boil down to the same thing (after doing dynamic date resolution): find if one date is after another date. In other words, the same left expression, the same operator, but different right expressions. NSPredicateEditor doesn’t support this. We need to find a way around this.

  2. NSPredicate doesn’t have any obvious date addition or date subtraction features. We need to figure out how to add and subtract arbitrary time intervals from arbitrary dates.

The Solutions

While these may seem like road-blocker problems, they’re really not too bad.

Solving the operator problem

The key to tricking NSPredicateEditor into showing different right expressions for two operators that are fundamentally the same is to not use the same operator. These are the operators that NSPredicateEditor recognizes:

typedef enum {
   NSLessThanPredicateOperatorType = 0,
   NSLessThanOrEqualToPredicateOperatorType,
   NSGreaterThanPredicateOperatorType,
   NSGreaterThanOrEqualToPredicateOperatorType,
   NSEqualToPredicateOperatorType,
   NSNotEqualToPredicateOperatorType,
   NSMatchesPredicateOperatorType,
   NSLikePredicateOperatorType,
   NSBeginsWithPredicateOperatorType,
   NSEndsWithPredicateOperatorType,
   NSInPredicateOperatorType,
   NSCustomSelectorPredicateOperatorType,
   NSContainsPredicateOperatorType,
   NSBetweenPredicateOperatorType
} NSPredicateOperatorType;

You’ll notice that only six of 14 operators are for numeric comparisons (<, <=, >, >=, =, !=). The rest are for string comparisons. This means we have 7 or 8 operators that are sitting around unused when we’re doing a numeric comparison (and date comparisons are really numeric comparisons).

The other key to tricking NSPredicateEditor into showing different right expressions is to realize that NSPredicateEditor is just a UI. It doesn’t care what the actual underlying NSPredicate is. It’s just going to show whatever it’s given, even if that is different from the actual NSPredicate.

So the solution:

#define NSInTheLastPredicateOperatorType NSMatchesPredicateOperatorType
#define NSInTheNextPredicateOperatorType NSLikePredicateOperatorType

That’s it. Any time we want to show an “in the last”-style right expression, we’re going to tell the editor “this is the ‘matches’” operator. Then we’re just going to do a bit of handling in code to not actually use the matches operator, but the proper comparator instead. Simple!

Solving the relative date problem

Have you ever looked at how dates are represented in predicates? It’s quite interesting:

NSDate * d = [NSDate date];
NSLog(@"%@", [NSPredicate predicateWithFormat:@"%@ != 0", d]);
//logs "CAST(312333057.230785, "NSDate") != 0"

Well now, that’s curious. We’re taking some number and “casting” it as an NSDate. But what’s that number? Let’s do:

NSLog(@"%f", [d timeIntervalSinceReferenceDate]);
//logs "312333057.230785"

So this number is a time interval! This is very convenient. But what about all this casting? If we capture the left expression of this predicate, we will find that it’s an NSExpression of type NSFunctionExpressionType, and that the -function of this expression is @"castObject:toType:". Further introspection reveals that the -arguments to this function are two NSExpressions: The first is a constant value expression holding an NSNumber, whose -doubleValue is 312333057.230785, and the second is a constant value expression holding the string @"NSDate".

This is very important, because we learn 3 major things:

  1. There is an undocumented function for NSExpression: castObject:toType:.
  2. This function wants a number as its first argument
  3. This function wants a string (that represents a class) as its second argument.

Armed with this information, we can surmise:

  1. We can create predicates that have the string CAST() in them. We don’t have to rely on predicateWithFormat: to insert that for us.
  2. Any number, or any function that results in a number, can be used as the first parameter to a CAST() function and be cast to an NSDate. That number represents a time interval since the first instant of 1 January 2001, GMT (the reference date).
  3. We can cast objects to other kinds of classes

Let’s test each one of these things:

NSPredicate * p = [NSPredicate predicateWithFormat:@"%@ > CAST(0, 'NSDate')", [NSDate date]];
NSLog(@"%d", [p evaluateWithObject:nil]);  //logs "1".  right now is after our reference date!

p = [NSPredicate predicateWithFormat:@"%@ > CAST(1+2+3+4+5+6, 'NSDate')", [NSDate date]];
NSLog(@"%d", [p evaluateWithObject:nil]);  //logs "1".  right now is after 21 seconds after the reference date!

p = [NSPredicate predicateWithFormat:@"CAST(%@, 'NSNumber') > 0", [NSDate date]];
NSLog(@"%d", [p evaluateWithObject:nil]);  //logs "1". We can cast dates to their numerical equivalent!

So all three of our assumptions appear to be correct! The last one is the most interesting, however. If we pull out the leftExpression of the predicate and evaluate it, we get 312333057.230785. In other words, casting a date to a number simply converts it to its reference date time interval! Since we could cast a number to a date, it makes perfect sense that we can cast a date to a number.

The last trick is “how do we get the current date without having to substitute it in every time?”. Fortunately, there’s a very simple answer: now(). now() is one of the built-in functions supported by NSExpression, and it (conveniently enough) returns an NSDate representing the current date and time.

Now that we know this, we can construct relative date predicates! So for example, let’s do “someDate is in the last 30 days”. If a date is in the last 30 days, that means that someDate is after now - 30 days:

[NSPrediate predicateWithFormat:@"someDate > CAST(CAST(now(), 'NSNumber') - %d, 'NSDate')", (30*86400)];

Working from the inside-out, we have:

  • now() - retrieve the current NSDate
  • CAST(now(), 'NSNumber') - convert the current date into an NSNumber
  • CAST(now(), 'NSNumber') - %d - subtract a time interval from the current timestamp. This difference must be in seconds. 30*86400 is 30 days in seconds (there are 86,400 seconds in a day)
  • CAST(CAST(now(), 'NSNumber') - %d, 'NSDate') - once we’ve done our subtraction, cast the time interval back to an NSDate

At this point, we can compare it against our target date. We could’ve done the date subtraction on the value returned by the left expression, but there are some circumstances where that’s not a good idea (primarily when integrating with CoreData), so we’ll keep it in the right.

VoilĂ ! Date subtraction in an NSPredicate! Doing something like “<some date> is during <this week, this month, this year>" would be slightly more complex (it would involve a BETWEEN comparison [or something functionally identical]), but still do-able. Even more fun would be a predicate of the form “<some date> is during the <day, week, month, year> of <another date>”. Also very difficult, but again not impossible. Maybe we’ll do those in another post. Anyway…

The Row Template

Our row template is going to be very similar to the template in my earlier post, because (again) we’re doing NSTimeInterval manipulations. Let’s get started:

The Interface

@interface DDRelativeDateRowTemplate : NSPredicateEditorRowTemplate {
    NSPopUpButton * unitPopUpButton;
}

- (id) initWithLeftExpressions:(NSArray *)leftExpressions;

@end

Nothing new to see here.

The Implementation

#define NSInTheLastPredicateOperatorType NSMatchesPredicateOperatorType
#define NSInTheNextPredicateOperatorType NSLikePredicateOperatorType

Some #defines to make things a bit more readable in our code

@implementation DDRelativeDateRowTemplate

- (id) initWithLeftExpressions:(NSArray *)leftExpressions {
    NSAttributeType rightType = NSDoubleAttributeType; //NSTimeInterval is a typedef'd double
    NSComparisonPredicateModifier modifier = NSDirectPredicateModifier; //don't need "ANY" or "ALL"
    NSArray * operators = [NSArray arrayWithObjects:
                           [NSNumber numberWithUnsignedInteger:NSInTheLastPredicateOperatorType],
                           [NSNumber numberWithUnsignedInteger:NSInTheNextPredicateOperatorType],
                           nil];
    NSUInteger options = 0;
    return [super initWithLeftExpressions:leftExpressions
             rightExpressionAttributeType:rightType
                                 modifier:modifier
                                operators:operators
                                  options:options];
}

Our init method is almost identical to the init method in our previous template, with the exception that this time we’re only supporting 2 operators. Notice that we’re telling the predicate editor that these are the “matches” and “like” operators. Again, this is only to get around problem #1 (see above).

static NSString * unitNames[] = {@"seconds", @"minutes", @"hours", @"days", @"weeks"};
static NSInteger unitIntervals[] = {1, 60, 3600, 86400, 604800};
#define numberOfUnits() (sizeof(unitNames)/sizeof(unitNames[0]))

- (NSPopUpButton *) unitPopUpButton {
    if (unitPopUpButton == nil) {
        unitPopUpButton = [[NSPopUpButton alloc] initWithFrame:NSZeroRect pullsDown:NO];

        NSMenu * unitMenu = [unitPopUpButton menu];
        for (int i = 0; i < numberOfUnits(); ++i) {
            [unitMenu addItemWithTitle:unitNames[i] action:NULL keyEquivalent:@""];
        }
    }
    return unitPopUpButton;
}

- (void) dealloc {
    [unitPopUpButton release];
    [super dealloc];
}

- (void) getBase:(NSTimeInterval *)base unit:(NSInteger *)unit fromTimeInterval:(NSTimeInterval)timeInterval {
    if (base == nil || unit == nil) {
        [NSException raise:NSInvalidArgumentException format:@"base and unit cannot be nil"];
    }

    for (NSInteger unitIndex = numberOfUnits() - 1; unitIndex >= 0; unitIndex--) {
        if (timeInterval >= unitIntervals[unitIndex]) {
            *base = timeInterval / unitIntervals[unitIndex];
            *unit = unitIndex;
            return;
        }
    }
}

- (NSTimeInterval) timeIntervalFromBase:(NSTimeInterval)base unit:(NSInteger)unitIndex {
    if (unitIndex >= numberOfUnits()) {
        [NSException raise:NSInvalidArgumentException format:@"unitIndex beyond max allowed bounds (%d)", numberOfUnits()];
        return 0;
    }

    return (base * unitIntervals[unitIndex]);
}

This code is identical to the code that was in our previous template, so I won’t explain it here.

- (NSArray *) templateViews {
    NSMutableArray * views = [[super templateViews] mutableCopy];

    [views addObject:[self unitPopUpButton]];

    NSPopUpButton * operatorView = [views objectAtIndex:1];
    [[operatorView itemAtIndex:0] setTitle:@"is in the last"];
    [[operatorView itemAtIndex:1] setTitle:@"is in the next"];

    return [views autorelease];
}

Our -templateViews method is almost the same, except that we’re altering the title of of the operators. If we were to leave out that bit, then we would see “matches” and “is like” in the interface. By changing the title here, we’re providing a new default value for the operator title. Note that this can be changed via localization, and more importantly, your NSLocalizedString macros for generating the strings file should use the values listed here, not the “matches” and “is like” values. Also note that operators show up in the interface in the same order in which they’re given in the -init method.

- (double) matchForPredicate:(NSPredicate *)predicate {
    if (![predicate isKindOfClass:[NSComparisonPredicate class]]) { goto errorExit; }

    NSComparisonPredicate * comparison = (NSComparisonPredicate *)predicate;

    NSExpression * left = [comparison leftExpression];
    if (![[self leftExpressions] containsObject:left]) { goto errorExit; }

    NSPredicateOperatorType operator = [comparison predicateOperatorType];
    if (operator != NSGreaterThanPredicateOperatorType && operator != NSLessThanPredicateOperatorType) { goto errorExit; }

    NSExpression * right = [comparison rightExpression];
    if ([right expressionType] != NSFunctionExpressionType) { goto errorExit; }
    if (![[right function] isEqual:@"castObject:toType:"]) { goto errorExit; }

    NSArray * outerCastArguments = [right arguments];
    //we can pull these out without bounds checking because a CAST() function *must* have 2 arguments

    NSExpression * firstArgument = [outerCastArguments objectAtIndex:0];
    {
        //the first argument must be either an addition or subtraction function
        if ([firstArgument expressionType] != NSFunctionExpressionType) { goto errorExit; }
        if (![[firstArgument function] isEqual:@"add:to:"] && ![[firstArgument function] isEqual:@"from:subtract:"]) { goto errorExit; }
        NSArray * relativeArguments = [firstArgument arguments];
        NSExpression * firstRelativeArgument = [relativeArguments objectAtIndex:0];
        {
            if ([firstRelativeArgument expressionType] != NSFunctionExpressionType) { goto errorExit; }
            if (![[firstRelativeArgument function] isEqual:@"castObject:toType:"]) { goto errorExit; }
            NSArray * innerCastArguments = [firstRelativeArgument arguments];
            NSExpression * firstInnerArgument = [innerCastArguments objectAtIndex:0];
            if ([firstInnerArgument expressionType] != NSFunctionExpressionType) { goto errorExit; }
            if (![[firstInnerArgument function] isEqual:@"now"]) { goto errorExit; }

            NSExpression * secondInnerArgument = [innerCastArguments objectAtIndex:1];
            if ([secondInnerArgument expressionType] != NSConstantValueExpressionType) { goto errorExit; }
            if (![[secondInnerArgument constantValue] isEqual:@"NSNumber"]) { goto errorExit; }
        }

        NSExpression * secondRelativeArgument = [relativeArguments objectAtIndex:1];
        {
            //the second relative argument is the actual relative difference, so it must be an NSNumber
            if ([secondRelativeArgument expressionType] != NSConstantValueExpressionType) { goto errorExit; }
            if (![[secondRelativeArgument constantValue] isKindOfClass:[NSNumber class]]) { goto errorExit; }
        }
    }

    NSExpression * secondArgument = [outerCastArguments objectAtIndex:1];
    {
        if ([secondArgument expressionType] != NSConstantValueExpressionType) { goto errorExit; }
        if (![[secondArgument constantValue] isEqual:@"NSDate"]) { goto errorExit; }
    }

    return DBL_MAX;

errorExit:
    return 0.0;
}

This is a beast of a method. This is how we determine if this row template recognizes a predicate or not. Basically what we’re doing here is making sure that the predicate has a extremely specific structure. If every requirement is met, then we return DBL_MAX, which is NSPredicateEditorRowTemplate's way of saying “I match this predicate better than anyone else ever could”. When NSPredicateEditor finds that multiple row templates match a predicate, it uses the row template that returns the highest match value. DBL_MAX is just a cheap way of ensuring that no other template can handle this predicate.

The expected structure is as follows: (each box is an NSExpression, and downward arrows indicate arguments to a function expression)

The NSExpression structure for relative dates

And yes, I use goto all over the place in that code. Deal with it.

- (NSPredicate *) predicateWithSubpredicates:(NSArray *)subpredicates {
    NSPredicate * predicate = [super predicateWithSubpredicates:subpredicates];
    if ([predicate isKindOfClass:[NSComparisonPredicate class]]) {
        NSComparisonPredicate * comparison = (NSComparisonPredicate *)predicate;

        NSExpression * right = [comparison rightExpression];
        NSNumber * value = [right constantValue];

        NSInteger unit = [[self unitPopUpButton] indexOfSelectedItem];

        NSTimeInterval newInterval = [self timeIntervalFromBase:[value doubleValue] unit:unit];
        value = [NSNumber numberWithDouble:newInterval];
        right = [NSExpression expressionForConstantValue:value];

        //default values are for NSInTheLastPredicateOperatorType
        NSString * function = @"from:subtract:";
        NSPredicateOperatorType newOperator = NSGreaterThanPredicateOperatorType;

        if ([comparison predicateOperatorType] == NSInTheNextPredicateOperatorType) {
            function = @"add:to:";
            newOperator = NSLessThanPredicateOperatorType;
        }

        //"now()" is a function and takes no arguments
        NSExpression * now = [NSExpression expressionForFunction:@"now" arguments:[NSArray array]];

        //CAST(now(), 'NSNumber')
        NSArray * castNowArguments = [NSArray arrayWithObjects:now, [NSExpression expressionForConstantValue:@"NSNumber"], nil];
        NSExpression * castNow = [NSExpression expressionForFunction:@"castObject:toType:" arguments:castNowArguments];

        //CAST(now(), 'NSNumber') [+/-] {a time interval}
        NSArray * relativeTimestampArguments = [NSArray arrayWithObjects:castNow, right, nil];
        NSExpression * relativeTimestamp = [NSExpression expressionForFunction:function arguments:relativeTimestampArguments];

        //CAST(CAST(now(), 'NSNumber') [+/-] {a time interval}, 'NSDate')
        NSArray * castToDateArguments = [NSArray arrayWithObjects:relativeTimestamp, [NSExpression expressionForConstantValue:@"NSDate"], nil];
        NSExpression * castToDate = [NSExpression expressionForFunction:@"castObject:toType:" arguments:castToDateArguments];

        predicate = [NSComparisonPredicate predicateWithLeftExpression:[comparison leftExpression] 
                                                       rightExpression:castToDate 
                                                              modifier:[comparison comparisonPredicateModifier] 
                                                                  type:newOperator 
                                                               options:[comparison options]];
    }
    return predicate;
}

This method is similar in principle to its counterpart in our earlier row template. Here we’re letting super give us a predicate that contains what the user entered into the interval field, and then we extract that number, convert it to an absolute time interval, and then re-create the right expression of the comparison predicate. We create the expression manually to ensure that the structure returned here exactly matches the structure expected by -matchForPredicate:. Notice that we’re changing our matches and like operators to the expected less than and greater than versions.

- (void) setPredicate:(NSPredicate *)predicate {
    if ([predicate isKindOfClass:[NSComparisonPredicate class]]) {
        NSComparisonPredicate * comparison = (NSComparisonPredicate *)predicate;
        /**
         I know that to get here, I must have matched the predicate.
         Therefore I can drill into the comparison predicate structure with reckless abandon!
         **/
        NSExpression * right = [comparison rightExpression];
        NSArray * arguments = [right arguments];

        //this expression is our addition or subtraction function
        NSExpression * relative = [arguments objectAtIndex:0];

        //the second argument to our relative function is our absolute time interval
        NSExpression * intervalExpression = [[relative arguments] objectAtIndex:1];
        NSNumber * interval = [intervalExpression constantValue];

        NSTimeInterval base = 0;
        NSInteger unit = 0;
        [self getBase:&base unit:&unit fromTimeInterval:[interval doubleValue]];

        [[self unitPopUpButton] selectItemAtIndex:unit];

        NSExpression * newRight = [NSExpression expressionForConstantValue:[NSNumber numberWithDouble:base]];
        NSPredicateOperatorType newOperator = NSInTheLastPredicateOperatorType;
        if ([comparison predicateOperatorType] == NSLessThanPredicateOperatorType) {
            newOperator = NSInTheNextPredicateOperatorType;
        }

        predicate = [NSComparisonPredicate predicateWithLeftExpression:[comparison leftExpression] 
                                                       rightExpression:newRight
                                                              modifier:[comparison comparisonPredicateModifier] 
                                                                  type:newOperator 
                                                               options:[comparison options]];
    }
    [super setPredicate:predicate];
}

@end

And finally! When given a predicate, we need to reflect those values in the UI. Here we’re simply going to extract the absolute time interval that’s buried in the rightExpression and use it to figure out what our base and unit should be. We also change the operator from greater than or less than to likes or matches, simply so that the operator popup shows the right value. After that, we re-package things, and send it on up to super.

Wrap-up

Not too bad, eh? Grokking this involves a little bit of mental gymnastics, but if you’re familiar with predicates and their internal structure, most of this code should be really straight-forward. This was mainly to help connect all those disparate dots and form a coherent picture. The more you play with these row templates, the more you’ll truly understand how they interact with each other and their parent predicate editor. They’re quite clever little classes, and they can really make our apps that much more “Mac-like”.

  1. funwithobjc posted this
Short URL for this post: http://tmblr.co/Zt522y1ZztoV
blog comments powered by Disqus