// // SPRuleFilterController.m // sequel-pro // // Created by Max Lohrmann on 04.05.18. // Copyright (c) 2018 Max Lohrmann. All rights reserved. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation // files (the "Software"), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following // conditions: // // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR // OTHER DEALINGS IN THE SOFTWARE. // // More info at #import "SPRuleFilterController.h" #import "SPQueryController.h" #import "SPDatabaseDocument.h" #import "RegexKitLite.h" #import "SPContentFilterManager.h" #import "SPFunctions.h" #import "SPTableFilterParser.h" typedef NS_ENUM(NSInteger, RuleNodeType) { RuleNodeTypeColumn, RuleNodeTypeString, RuleNodeTypeOperator, RuleNodeTypeArgument, RuleNodeTypeConnector, }; NSString * const SPRuleFilterHeightChangedNotification = @"SPRuleFilterHeightChanged"; /** * The type of filter rule that the current item represents. */ const NSString * const SerFilterClass = @"filterClass"; /** * The current rule is a group row (an "AND" or "OR" expression with children) */ const NSString * const SerFilterClassGroup = @"groupNode"; /** * The current rule is a filter expression */ const NSString * const SerFilterClassExpression = @"expressionNode"; /** * Group Nodes only: * Indicates whether the group is a conjunction. * If YES, the children will be combined using "AND", otherwise using "OR". */ const NSString * const SerFilterGroupIsConjunction = @"isConjunction"; /** * Group Nodes only: * An array of child filter rules (which again can be group or expression rules) */ const NSString * const SerFilterGroupChildren = @"children"; /** * Expression Nodes only: * The name of the column to filter in (left side expression) * * Legacy names: * @"filterField", fieldField */ const NSString * const SerFilterExprColumn = @"column"; /** * Expression Nodes only: * The data type grouping of the column for applicable filters */ const NSString * const SerFilterExprType = @"filterType"; /** * Expression Nodes only: * The title of the filter operator to apply * * Legacy names: * @"filterComparison", compareField */ const NSString * const SerFilterExprComparison = @"filterComparison"; /** * Expression Nodes only: * The values to apply the filter with (an array of 0 or more elements) * * Legacy names: * @"filterValue", argumentField * @"firstBetweenField", @"secondBetweenField", firstBetweenField, secondBetweenField */ const NSString * const SerFilterExprValues = @"filterValues"; /** * Expression Nodes only: * the filter definition dictionary (as in ContentFilters.plist) * for the filter represented by SerFilterExprComparison. * * This item is not designed to be serialized to disk */ const NSString * const SerFilterExprDefinition = @"_filterDefinition"; #pragma mark - @interface RuleNode : NSObject { RuleNodeType type; } @property(assign, nonatomic) RuleNodeType type; @end @interface ColumnNode : RuleNode { NSString *name; NSString *typegrouping; NSArray *operatorCache; NSUInteger opCacheVersion; } @property(copy, nonatomic) NSString *name; @property(copy, nonatomic) NSString *typegrouping; @property(retain, nonatomic) NSArray *operatorCache; @property(assign, nonatomic) NSUInteger opCacheVersion; @end @interface StringNode : RuleNode { NSString *value; } @property(copy, nonatomic) NSString *value; @end @interface OpNode : RuleNode { // Note: The main purpose of this field is to have @"=" for column A and @"=" for column B to return NO in -isEqual: // because otherwise NSRuleEditor will get confused and blow up. ColumnNode *parentColumn; NSDictionary *settings; NSDictionary *filter; } @property (assign, nonatomic) ColumnNode *parentColumn; @property (retain, nonatomic) NSDictionary *settings; @property (retain, nonatomic) NSDictionary *filter; @end @interface ArgNode : RuleNode { NSDictionary *filter; NSUInteger argIndex; NSString *initialValue; } @property (copy, nonatomic) NSString *initialValue; @property (retain, nonatomic) NSDictionary *filter; @property (assign, nonatomic) NSUInteger argIndex; @end @interface ConnectorNode : RuleNode { NSDictionary *filter; NSUInteger labelIndex; } @property (retain, nonatomic) NSDictionary *filter; @property (assign, nonatomic) NSUInteger labelIndex; @end #pragma mark - /** * TODO: * This class shouldn't even exist to begin with. * Its sad story begins with this call in `-[SPRuleFilterController dealloc]`: * * [filterRuleEditor unbind:@"rows"]; * * `-dealloc` may not be the best method to undo what we did in `-awakeFromNib`, but it's the only thing we have. * Also we have to unbind this object, or we may receive zombie calls later on because the binding is unretained. * Which brings us to another huge mistake Apple made in the implementation of -unbind. The call looks like this: * * - [NSRulEditor unbind:] * - [NSRuleEditor _rootRowsArray] * - [NSRuleEditor->_boundArrayOwner mutableArrayValueForKeyPath:NSRuleEditor->_boundArrayKeyPath] * * -mutableArrayValueForKeyPath: is the culprit here since it does not return the object itself ("model") but * instead returns an autoreleased proxy object which retains the parent object of the key. * * That explains why we can't put "model" into SPRuleFilterController: * The `-[NSRuleEditor unbind:]` would cause a call to `-[SPRuleFilterController retain]` from within * `-[SPRuleFilterController dealloc]` (which is pointless since there is no way out from -dealloc). * This wouldn't be a problem if the proxy object was released again while dealloc is still on the stack, but * since it is autoreleased we end up with a zombie call again. * * ModelContainer is a dummy intermediate to prevent this, since it is still valid when we enter -dealloc and * trigger -unbind and thus can handle the -retain by the proxy object. */ @interface ModelContainer : NSObject { NSMutableArray *model; } // This is the binding used by NSRuleEditor for the current state @property (retain, nonatomic) NSMutableArray *model; @end #pragma mark - @interface SPRuleFilterController () @property (readwrite, assign, nonatomic) CGFloat preferredHeight; - (NSArray *)_compareTypesForColumn:(ColumnNode *)colNode; - (IBAction)_textFieldAction:(id)sender; - (IBAction)_editFiltersAction:(id)sender; - (void)_contentFiltersHaveBeenUpdated:(NSNotification *)notification; + (NSDictionary *)_flattenSerializedFilter:(NSDictionary *)in; static BOOL SerIsGroup(NSDictionary *dict); - (NSDictionary *)_serializedFilterIncludingFilterDefinition:(BOOL)includeDefinition; + (void)_writeFilterTree:(NSDictionary *)in toString:(NSMutableString *)out wrapInParenthesis:(BOOL)wrap binary:(BOOL)isBINARY error:(NSError **)err; - (NSMutableDictionary *)_restoreSerializedFilter:(NSDictionary *)serialized; static void _addIfNotNil(NSMutableArray *array, id toAdd); - (ColumnNode *)_columnForName:(NSString *)name; - (OpNode *)_operatorNamed:(NSString *)title forColumn:(ColumnNode *)col; - (BOOL)_focusOnFieldInSubtree:(NSDictionary *)dict; - (void)_resize; - (void)openContentFilterManagerForFilterType:(NSString *)filterType; - (IBAction)filterTable:(id)sender; - (IBAction)_menuItemInRuleEditorClicked:(id)sender; - (void)_pretendPlayRuleEditorForCriteria:(NSMutableArray *)criteria displayValues:(NSMutableArray *)displayValues inRow:(NSInteger)row; - (void)_ensureValidOperatorCache:(ColumnNode *)col; static BOOL _arrayContainsInViewHierarchy(NSArray *haystack, id needle); @end @implementation SPRuleFilterController @synthesize preferredHeight = preferredHeight; @synthesize target = target; @synthesize action = action; - (instancetype)init { if((self = [super init])) { columns = [[NSMutableArray alloc] init]; _modelContainer = [[ModelContainer alloc] init]; preferredHeight = 0.0; target = nil; action = NULL; opNodeCacheVersion = 1; // Init default filters for Content Browser contentFilters = [[NSMutableDictionary alloc] init]; numberOfDefaultFilters = [[NSMutableDictionary alloc] init]; NSError *readError = nil; NSString *filePath = [NSBundle pathForResource:@"ContentFilters.plist" ofType:nil inDirectory:[[NSBundle mainBundle] bundlePath]]; NSData *defaultFilterData = [NSData dataWithContentsOfFile:filePath options:NSMappedRead error:&readError]; if(defaultFilterData && !readError) { NSDictionary *defaultFilterDict = [NSPropertyListSerialization propertyListWithData:defaultFilterData options:NSPropertyListMutableContainersAndLeaves format:NULL error:&readError]; if(defaultFilterDict && !readError) { [contentFilters setDictionary:defaultFilterDict]; } } if (readError) { NSLog(@"Error while reading 'ContentFilters.plist':\n%@", readError); NSBeep(); } else { [numberOfDefaultFilters setObject:[NSNumber numberWithInteger:[[contentFilters objectForKey:@"number"] count]] forKey:@"number"]; [numberOfDefaultFilters setObject:[NSNumber numberWithInteger:[[contentFilters objectForKey:@"date"] count]] forKey:@"date"]; [numberOfDefaultFilters setObject:[NSNumber numberWithInteger:[[contentFilters objectForKey:@"string"] count]] forKey:@"string"]; [numberOfDefaultFilters setObject:[NSNumber numberWithInteger:[[contentFilters objectForKey:@"spatial"] count]] forKey:@"spatial"]; } } return self; } - (void)awakeFromNib { [filterRuleEditor bind:@"rows" toObject:_modelContainer withKeyPath:@"model" options:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_contentFiltersHaveBeenUpdated:) name:SPContentFiltersHaveBeenUpdatedNotification object:nil]; } - (void)focusFirstInputField { for(NSDictionary *rootItem in [_modelContainer model]) { if([self _focusOnFieldInSubtree:rootItem]) return; } } - (BOOL)_focusOnFieldInSubtree:(NSDictionary *)dict { //if we are a simple row we might have an input field ourself, otherwise search among our children if([[dict objectForKey:@"rowType"] unsignedIntegerValue] == NSRuleEditorRowTypeSimple) { for(id obj in [dict objectForKey:@"displayValues"]) { if([obj isKindOfClass:[NSTextField class]]) { [[(NSTextField *)obj window] makeFirstResponder:obj]; return YES; } } } else { for(NSDictionary *child in [dict objectForKey:@"subrows"]) { if([self _focusOnFieldInSubtree:child]) return YES; } } return NO; } - (void)setColumns:(NSArray *)dataColumns; { // we have to access the model in the same way the rule editor does for it to realize the changes [[_modelContainer mutableArrayValueForKey:@"model"] removeAllObjects]; [columns removeAllObjects]; //without a table there is nothing to filter if(dataColumns) { //sort column names if enabled NSArray *columnDefinitions = dataColumns; if ([[NSUserDefaults standardUserDefaults] boolForKey:SPAlphabeticalTableSorting]) { NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"name" ascending:YES]; columnDefinitions = [columnDefinitions sortedArrayUsingDescriptors:@[sortDescriptor]]; } // get the columns for (NSDictionary *colDef in columnDefinitions) { ColumnNode *node = [[ColumnNode alloc] init]; [node setName:[colDef objectForKey:@"name"]]; [node setTypegrouping:[colDef objectForKey:@"typegrouping"]]; [columns addObject:node]; [node release]; } } // make the rule editor reload the criteria [filterRuleEditor reloadCriteria]; // disable UI if no criteria exist [self setEnabled:([columns count] != 0)]; } - (NSInteger)ruleEditor:(NSRuleEditor *)editor numberOfChildrenForCriterion:(nullable id)criterion withRowType:(NSRuleEditorRowType)rowType { // nil criterion is always the first element in a row, compound rows are only for "AND"/"OR" groups if(!criterion && rowType == NSRuleEditorRowTypeCompound) { return 2; } else if(!criterion && rowType == NSRuleEditorRowTypeSimple) { return [columns count]; } else if(rowType == NSRuleEditorRowTypeSimple) { // the children of the columns are their operators RuleNodeType type = [(RuleNode *)criterion type]; if(type == RuleNodeTypeColumn) { ColumnNode *node = (ColumnNode *)criterion; [self _ensureValidOperatorCache:node]; return [[node operatorCache] count]; } // the first child of an operator is the first argument (if it has one) else if(type == RuleNodeTypeOperator) { OpNode *node = (OpNode *)criterion; NSInteger numOfArgs = [[[node filter] objectForKey:@"NumberOfArguments"] integerValue]; return (numOfArgs > 0) ? 1 : 0; } // the child of an argument can only be the conjunction label if more arguments follow else if(type == RuleNodeTypeArgument) { ArgNode *node = (ArgNode *)criterion; NSInteger numOfArgs = [[[node filter] objectForKey:@"NumberOfArguments"] integerValue]; return (numOfArgs > [node argIndex]+1) ? 1 : 0; } // the child of a conjunction is the next argument, if we have one else if(type == RuleNodeTypeConnector) { ConnectorNode *node = (ConnectorNode *)criterion; NSInteger numOfArgs = [[[node filter] objectForKey:@"NumberOfArguments"] integerValue]; return (numOfArgs > [node labelIndex]+1) ? 1 : 0; } } return 0; } - (id)ruleEditor:(NSRuleEditor *)editor child:(NSInteger)index forCriterion:(nullable id)criterion withRowType:(NSRuleEditorRowType)rowType { // nil criterion is always the first element in a row, compound rows are only for "AND"/"OR" groups if(!criterion && rowType == NSRuleEditorRowTypeCompound) { StringNode *node = [[StringNode alloc] init]; switch(index) { case 0: [node setValue:@"AND"]; break; case 1: [node setValue:@"OR"]; break; } return [node autorelease]; } // this is the column field else if(!criterion && rowType == NSRuleEditorRowTypeSimple) { return [columns objectAtIndex:index]; } else if(rowType == NSRuleEditorRowTypeSimple) { // the children of the columns are their operators RuleNodeType type = [(RuleNode *) criterion type]; if (type == RuleNodeTypeColumn) { return [[criterion operatorCache] objectAtIndex:index]; } // the first child of an operator is the first argument else if(type == RuleNodeTypeOperator) { NSDictionary *filter = [(OpNode *)criterion filter]; if([[filter objectForKey:@"NumberOfArguments"] integerValue]) { ArgNode *arg = [[ArgNode alloc] init]; [arg setFilter:filter]; [arg setArgIndex:0]; return [arg autorelease]; } } // the child of an argument can only be the conjunction label if more arguments follow else if(type == RuleNodeTypeArgument) { NSDictionary *filter = [(ArgNode *)criterion filter]; NSUInteger argIndex = [(ArgNode *)criterion argIndex]; if([[filter objectForKey:@"NumberOfArguments"] integerValue] > argIndex +1) { ConnectorNode *node = [[ConnectorNode alloc] init]; [node setFilter:filter]; [node setLabelIndex:argIndex]; // label 0 follows argument 0 return [node autorelease]; } } // the child of a conjunction is the next argument, if we have one else if(type == RuleNodeTypeConnector) { ConnectorNode *node = (ConnectorNode *)criterion; NSInteger numOfArgs = [[[node filter] objectForKey:@"NumberOfArguments"] integerValue]; if(numOfArgs > [node labelIndex]+1) { ArgNode *arg = [[ArgNode alloc] init]; [arg setFilter:[node filter]]; [arg setArgIndex:([node labelIndex]+1)]; return [arg autorelease]; } } } return nil; } - (id)ruleEditor:(NSRuleEditor *)editor displayValueForCriterion:(id)criterion inRow:(NSInteger)row { switch([(RuleNode *)criterion type]) { case RuleNodeTypeString: return [(StringNode *)criterion value]; case RuleNodeTypeColumn: return [(ColumnNode *)criterion name]; case RuleNodeTypeOperator: { OpNode *node = (OpNode *)criterion; NSMenuItem *item; if ([[[node settings] objectForKey:@"isSeparator"] boolValue]) { item = [NSMenuItem separatorItem]; } else { /* NOTE: * Apple's doc on NSRuleEditor says that returning NSMenuItems is supported. * However there seems to be a major discrepancy between what Apple considers "supported" and what any * sane person would consider supported. * * Basically one would expect NSMenuItems to be handled in the same way a number of NSString children of a * row's element will be handled, but that was not Apples intention. By supported they actually mean * "Your app won't crash immediately if you return an NSMenuItem here" - but that's about it. * Even selecting such an NSMenuItem will already cause an exception on 10.6 and be treated as a NOOP on * later OSes. * So if we return NSMenuItems we have to implement the full logic of the NSRuleEditor for updating and * displaying the row ourselves, starting with handling the target/action of the NSMenuItems! */ item = [[NSMenuItem alloc] initWithTitle:[[node settings] objectForKey:@"title"] action:NULL keyEquivalent:@""]; [item setToolTip:[[node settings] objectForKey:@"tooltip"]]; [item setTag:[[[node settings] objectForKey:@"tag"] integerValue]]; [item setRepresentedObject:@{ @"node": node, // this one is needed by the "Edit filters…" item for context @"filterType": SPBoxNil([[node settings] objectForKey:@"filterType"]), }]; [item setTarget:self]; [item setAction:@selector(_menuItemInRuleEditorClicked:)]; [item autorelease]; } return item; } case RuleNodeTypeArgument: { //an argument is a textfield ArgNode *node = (ArgNode *)criterion; NSTextField *textField = [[NSTextField alloc] init]; [[textField cell] setSendsActionOnEndEditing:YES]; [[textField cell] setUsesSingleLineMode:YES]; [textField setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; [textField sizeToFit]; [textField setTarget:self]; [textField setAction:@selector(_textFieldAction:)]; if([node initialValue]) [textField setStringValue:[node initialValue]]; NSRect frame = [textField frame]; //adjust width, to make the field wider frame.size.width = 500; //TODO determine a good width (possibly from the field type size) - how to access the rule editors bounds? [textField setFrame:frame]; return [textField autorelease]; } case RuleNodeTypeConnector: { // a simple string for once ConnectorNode *node = (ConnectorNode *)criterion; NSArray* labels = [[node filter] objectForKey:@"ConjunctionLabels"]; return (labels && [labels count] == 1)? [labels objectAtIndex:0] : @""; } } return nil; } - (IBAction)_textFieldAction:(id)sender { // if the action was caused by pressing return or enter, trigger filtering NSEvent *event = [NSApp currentEvent]; if(event && [event type] == NSKeyDown && ([event keyCode] == 36 || [event keyCode] == 76)) { [self filterTable:nil]; } } - (IBAction)_menuItemInRuleEditorClicked:(id)sender { if(!sender) return; // NSRuleEditor will throw on nil NSInteger row = [filterRuleEditor rowForDisplayValue:sender]; if(row == NSNotFound) return; // unknown display values OpNode *node = [[(NSMenuItem *)sender representedObject] objectForKey:@"node"]; // if the row has an explicit handler, pass on the action and do nothing id _target = [[node settings] objectForKey:@"target"]; SEL _action = (SEL)[(NSValue *)[[node settings] objectForKey:@"action"] pointerValue]; if(_target && _action) { [_target performSelector:_action withObject:sender]; return; } /* now comes the painful part, where we'd have to find out where exactly in the row this * displayValue should appear. * * Luckily we know that this method will only be invoked by the displayValues of OpNode * and currently OpNode can only appear as the second node in a row (after the column). * * Annoyingly we can't tell the rule editor to just replace a single element. We actually * have to recalculate the whole row starting with the element we replaced - a task the * rule editor would normally do for us when using NSStrings! */ NSMutableArray *criteria = [[filterRuleEditor criteriaForRow:row] mutableCopy]; NSMutableArray *displayValues = [[filterRuleEditor displayValuesForRow:row] mutableCopy]; // find the position of the previous opnode (just for safety) NSUInteger opIndex = NSNotFound; NSUInteger i = 0; for(RuleNode *obj in criteria) { if([obj type] == RuleNodeTypeOperator) { opIndex = i; break; } i++; } if(opIndex < [criteria count]) { // yet another uglyness: if one of the displayValues is an input and currently the first responder // we have to manually restore that for the new input we create for UX reasons. // However an NSTextField is seldom a first responder, usually it's an invisible subview of the text field... id firstResponder = [[filterRuleEditor window] firstResponder]; BOOL hasFirstResponderInRow = _arrayContainsInViewHierarchy(displayValues, firstResponder); //remove previous opnode and everything that follows and append new opnode NSRange stripRange = NSMakeRange(opIndex, ([criteria count] - opIndex)); [criteria removeObjectsInRange:stripRange]; [criteria addObject:node]; //remove the display value for the old op node and everything that followed [displayValues removeObjectsInRange:stripRange]; //now we'll fill in everything again [self _pretendPlayRuleEditorForCriteria:criteria displayValues:displayValues inRow:row]; //and update the row to its new state [filterRuleEditor setCriteria:criteria andDisplayValues:displayValues forRowAtIndex:row]; if(hasFirstResponderInRow) { // make the next possible object after the opnode the new next responder (since the previous one is gone now) for (NSUInteger j = stripRange.location + 1; j < [displayValues count]; ++j) { id obj = [displayValues objectAtIndex:j]; if([obj respondsToSelector:@selector(acceptsFirstResponder)] && [obj acceptsFirstResponder]) { [[filterRuleEditor window] makeFirstResponder:obj]; break; } } } } [criteria release]; [displayValues release]; } BOOL _arrayContainsInViewHierarchy(NSArray *haystack, id needle) { //first, try it the easy way if([haystack indexOfObjectIdenticalTo:needle] != NSNotFound) return YES; // otherwise, if needle is a view, check if it appears as a desencdant of some other view in haystack Class NSViewClass = [NSView class]; if([needle isKindOfClass:NSViewClass]) { for(id obj in haystack) { if([obj isKindOfClass:NSViewClass] && [needle isDescendantOf:obj]) return YES; } } return NO; } /** * This method recursively fills up the passed-in criteria and displayValues arrays with objects in the way the * NSRuleEditor would, so they can be used with the -setCriteria:andDisplayValues:forRowAtIndex: call. * * Assumptions made: * - row is a valid row within the bounds of the rule editor * - criteria contains at least one object * - displayValues contains exactly one less object than criteria */ - (void)_pretendPlayRuleEditorForCriteria:(NSMutableArray *)criteria displayValues:(NSMutableArray *)displayValues inRow:(NSInteger)row { id curCriterion = [criteria lastObject]; //first fill in the display value for the current criterion id display = [self ruleEditor:filterRuleEditor displayValueForCriterion:curCriterion inRow:row]; if(!display) return; // abort if unset [displayValues addObject:display]; // now let's check if we have to go deeper NSRuleEditorRowType rowType = [filterRuleEditor rowTypeForRow:row]; if([self ruleEditor:filterRuleEditor numberOfChildrenForCriterion:curCriterion withRowType:rowType]) { // we only care for the first child, though id nextCriterion = [self ruleEditor:filterRuleEditor child:0 forCriterion:curCriterion withRowType:rowType]; if(nextCriterion) { [criteria addObject:nextCriterion]; [self _pretendPlayRuleEditorForCriteria:criteria displayValues:displayValues inRow:row]; } } } - (IBAction)filterTable:(id)sender { if(target && action) [target performSelector:action withObject:self]; } - (void)_resize { // The situation with the sizing is a bit f'ed up: // - When -ruleEditorRowsDidChange: is invoked the NSRuleEditor has not yet updated its required frame size // - We can't use KVO on -frame either, because SPTableContent will update the container size which // ultimately also updates the NSRuleEditor's frame, causing a loop // - Calling -sizeToFit works, but only when the NSRuleEditor is growing. It won't shrink // after removing rows. // - -intrinsicContentSize is what we want, but that method is 10.7+, so on 10.6 let's do the // easiest workaround (note that both -intrinsicContentSize and -sizeToFit internally use -[NSRuleEditor _minimumFrameHeight]) CGFloat wantsHeight; if([filterRuleEditor respondsToSelector:@selector(intrinsicContentSize)]) { NSSize sz = [filterRuleEditor intrinsicContentSize]; wantsHeight = sz.height; } else { wantsHeight = [filterRuleEditor rowHeight] * [filterRuleEditor numberOfRows]; } if(wantsHeight != preferredHeight) { [self setPreferredHeight:wantsHeight]; [[NSNotificationCenter defaultCenter] postNotificationName:SPRuleFilterHeightChangedNotification object:self]; } } - (void)ruleEditorRowsDidChange:(NSNotification *)notification { //TODO find a better way to trigger resize // We can't do this here, because it will cause rows to jump around when removing them (the add case works fine, though) [self performSelector:@selector(_resize) withObject:nil afterDelay:0.2]; //[self _resize]; } - (void)dealloc { [filterRuleEditor unbind:@"rows"]; [[NSNotificationCenter defaultCenter] removeObserver:self]; // WARNING: THIS MUST COME AFTER -unbind:! See the class comment on ModelContainer for the reasoning SPClear(_modelContainer); SPClear(columns); SPClear(contentFilters); SPClear(numberOfDefaultFilters); [super dealloc]; } /** * Sets the compare types for the filter and the appropriate formatter for the textField */ - (NSArray *)_compareTypesForColumn:(ColumnNode *)colNode { if(contentFilters == nil || ![contentFilters objectForKey:@"number"] || ![contentFilters objectForKey:@"string"] || ![contentFilters objectForKey:@"date"]) { NSLog(@"Error while setting filter types."); NSBeep(); return @[]; } NSString *fieldTypeGrouping; if([colNode typegrouping]) { fieldTypeGrouping = [NSString stringWithString:[colNode typegrouping]]; } else { return @[]; } NSMutableArray *compareItems = [NSMutableArray array]; NSString *compareType; if ( [fieldTypeGrouping isEqualToString:@"date"] ) { compareType = @"date"; /* if ([fieldType isEqualToString:@"timestamp"]) { [argumentField setFormatter:[[NSDateFormatter alloc] initWithDateFormat:@"%Y-%m-%d %H:%M:%S" allowNaturalLanguage:YES]]; } if ([fieldType isEqualToString:@"datetime"]) { [argumentField setFormatter:[[NSDateFormatter alloc] initWithDateFormat:@"%Y-%m-%d %H:%M:%S" allowNaturalLanguage:YES]]; } if ([fieldType isEqualToString:@"date"]) { [argumentField setFormatter:[[NSDateFormatter alloc] initWithDateFormat:@"%Y-%m-%d" allowNaturalLanguage:YES]]; } if ([fieldType isEqualToString:@"time"]) { [argumentField setFormatter:[[NSDateFormatter alloc] initWithDateFormat:@"%H:%M:%S" allowNaturalLanguage:YES]]; } if ([fieldType isEqualToString:@"year"]) { [argumentField setFormatter:[[NSDateFormatter alloc] initWithDateFormat:@"%Y" allowNaturalLanguage:YES]]; } */ // TODO: A bug in the framework previously meant enum fields had to be treated as string fields for the purposes // of comparison - this can now be split out to support additional comparison fucntionality if desired. } else if ([fieldTypeGrouping isEqualToString:@"string"] || [fieldTypeGrouping isEqualToString:@"binary"] || [fieldTypeGrouping isEqualToString:@"textdata"] || [fieldTypeGrouping isEqualToString:@"blobdata"] || [fieldTypeGrouping isEqualToString:@"enum"]) { compareType = @"string"; // [argumentField setFormatter:nil]; } else if ([fieldTypeGrouping isEqualToString:@"bit"] || [fieldTypeGrouping isEqualToString:@"integer"] || [fieldTypeGrouping isEqualToString:@"float"]) { compareType = @"number"; // [argumentField setFormatter:numberFormatter]; } else if ([fieldTypeGrouping isEqualToString:@"geometry"]) { compareType = @"spatial"; } else { compareType = @""; NSBeep(); NSLog(@"ERROR: unknown type for comparision: in %@", fieldTypeGrouping); } // Add IS NULL and IS NOT NULL as they should always be available // [compareField addItemWithTitle:@"IS NULL"]; // [compareField addItemWithTitle:@"IS NOT NULL"]; // Remove user-defined filters first if([numberOfDefaultFilters objectForKey:compareType]) { NSUInteger cycles = [[contentFilters objectForKey:compareType] count] - [[numberOfDefaultFilters objectForKey:compareType] integerValue]; while(cycles > 0) { [[contentFilters objectForKey:compareType] removeLastObject]; cycles--; } } NSUserDefaults *prefs = [NSUserDefaults standardUserDefaults]; #ifndef SP_CODA /* content filters */ // Load global user-defined content filters if([prefs objectForKey:SPContentFilters] && [contentFilters objectForKey:compareType] && [[prefs objectForKey:SPContentFilters] objectForKey:compareType]) { [[contentFilters objectForKey:compareType] addObjectsFromArray:[[prefs objectForKey:SPContentFilters] objectForKey:compareType]]; } // Load doc-based user-defined content filters if([[SPQueryController sharedQueryController] contentFilterForFileURL:[tableDocumentInstance fileURL]]) { id filters = [[SPQueryController sharedQueryController] contentFilterForFileURL:[tableDocumentInstance fileURL]]; if([filters objectForKey:compareType]) [[contentFilters objectForKey:compareType] addObjectsFromArray:[filters objectForKey:compareType]]; } #endif NSUInteger i = 0; if([contentFilters objectForKey:compareType]) { for (id filter in [contentFilters objectForKey:compareType]) { // Create the tooltip NSString *tooltip; if ([filter objectForKey:@"Tooltip"]) tooltip = [filter objectForKey:@"Tooltip"]; else { NSMutableString *tip = [[NSMutableString alloc] init]; if ([filter objectForKey:@"Clause"] && [(NSString *) [filter objectForKey:@"Clause"] length]) { [tip setString:[[filter objectForKey:@"Clause"] stringByReplacingOccurrencesOfRegex:@"(? [values count]) { SPLog(@"filter operator %@ requires %ld arguments, but only have %ld stored values!",op,numOfArgs,[values count]); goto fail; } // otherwise add them for (NSUInteger i = 0; i < numOfArgs; ++i) { // insert connector node between args? if(i > 0) { ConnectorNode *node = [[ConnectorNode alloc] init]; [node setFilter:[op filter]]; [node setLabelIndex:(i-1)]; // label 0 follows argument 0 [criteria addObject:node]; [node release]; } ArgNode *arg = [[ArgNode alloc] init]; [arg setArgIndex:i]; [arg setFilter:[op filter]]; [arg setInitialValue:[values objectAtIndex:i]]; [criteria addObject:arg]; [arg release]; } [obj setObject:criteria forKey:@"criteria"]; //the last thing that remains is creating the displayValues for all criteria NSMutableArray *displayValues = [NSMutableArray arrayWithCapacity:[criteria count]]; for(id criterion in criteria) { id dispValue = [self ruleEditor:filterRuleEditor displayValueForCriterion:criterion inRow:-1]; if(!dispValue) { SPLog(@"got nil displayValue for criterion %@ on deserialization!",criterion); goto fail; } [displayValues addObject:dispValue]; } [obj setObject:displayValues forKey:@"displayValues"]; } return [obj autorelease]; fail: [obj release]; return nil; } + (NSDictionary *)makeSerializedFilterForColumn:(NSString *)colName operator:(NSString *)opName values:(NSArray *)values { return @{ SerFilterClass: SerFilterClassExpression, SerFilterExprColumn: colName, SerFilterExprComparison: opName, SerFilterExprValues: values, }; } - (ColumnNode *)_columnForName:(NSString *)name { if([name length]) { for (ColumnNode *col in columns) { if ([name isEqualToString:[col name]]) return col; } } return nil; } - (OpNode *)_operatorNamed:(NSString *)title forColumn:(ColumnNode *)col { if([title length]) { // check if we have the operator cache, otherwise build it [self _ensureValidOperatorCache:col]; // try to find it in the operator cache for(OpNode *node in [col operatorCache]) { if([[[node filter] objectForKey:@"MenuLabel"] isEqualToString:title]) return node; } } return nil; } BOOL SerIsGroup(NSDictionary *dict) { return [SerFilterClassGroup isEqual:[dict objectForKey:SerFilterClass]]; } /** * This method looks at the given serialized filter in a recursive manner and * when it encounters * - a group node with only a single child or * - a child that is a group node of the same kind as the parent one * it will pull the child(ren) up * * So for example: * AND(expr1) => expr1 * AND(expr1,AND(expr2,expr3)) => AND(expr1,expr2,expr3) * * The input dict is not modified, the returned dict will be equal to the input * dict or have parts of it removed or replaced with new dicts. */ + (NSDictionary *)_flattenSerializedFilter:(NSDictionary *)in { // return non-group-nodes as is if(!SerIsGroup(in)) return in; NSNumber *inIsConjunction = [in objectForKey:SerFilterGroupIsConjunction]; // first give all children the chance to flatten (depth first) NSArray *children = [in objectForKey:SerFilterGroupChildren]; NSMutableArray *flatChildren = [NSMutableArray arrayWithCapacity:[children count]]; NSUInteger changed = 0; for(NSDictionary *child in children) { NSDictionary *flattened = [self _flattenSerializedFilter:child]; //take a closer look at the (possibly changed) child - is it a group node of the same kind as us? if(SerIsGroup(flattened) && [inIsConjunction isEqual:[flattened objectForKey:SerFilterGroupIsConjunction]]) { [flatChildren addObjectsFromArray:[flattened objectForKey:SerFilterGroupChildren]]; changed++; } else if(flattened != child) { changed++; } [flatChildren addObject:flattened]; } // if there is only a single child, return it (flattening) if([flatChildren count] == 1) return [flatChildren objectAtIndex:0]; // if none of the children changed return the original input if(!changed) return in; // last variant: some of our children changed, but we remain return @{ SerFilterClass: SerFilterClassGroup, SerFilterGroupIsConjunction: inIsConjunction, SerFilterGroupChildren: flatChildren }; } + (void)_writeFilterTree:(NSDictionary *)in toString:(NSMutableString *)out wrapInParenthesis:(BOOL)wrap binary:(BOOL)isBINARY error:(NSError **)err { NSError *myErr = nil; if(wrap) [out appendString:@"("]; if(SerIsGroup(in)) { BOOL isConjunction = [[in objectForKey:SerFilterGroupIsConjunction] boolValue]; NSString *connector = isConjunction ? @"AND" : @"OR"; BOOL first = YES; NSArray *children = [in objectForKey:SerFilterGroupChildren]; for(NSDictionary *child in children) { if(!first) [out appendFormat:@" %@ ",connector]; else first = NO; // if the child is a group node but of a different kind we want to wrap it in order to prevent operator precedence confusion // expression children will always be wrapped for clarity, except if there is only a single one and we are already wrapped BOOL wrapChild = YES; if(SerIsGroup(child)) { BOOL childIsConjunction = [[child objectForKey:SerFilterGroupIsConjunction] boolValue]; if(isConjunction == childIsConjunction) wrapChild = NO; } else { if(wrap && [children count] == 1) wrapChild = NO; } [self _writeFilterTree:child toString:out wrapInParenthesis:wrapChild binary:isBINARY error:&myErr]; if(myErr) { if(err) *err = myErr; return; } } } else { // finally - build a SQL filter expression NSDictionary *filter = [in objectForKey:SerFilterExprDefinition]; if(!filter) { if(err) *err = [NSError errorWithDomain:SPErrorDomain code:0 userInfo:@{ NSLocalizedDescriptionKey: NSLocalizedString(@"Fatal error while retrieving content filter. No filter definition found.", @"filter to sql conversion : internal error : 0"), }]; return; } if(![filter objectForKey:@"NumberOfArguments"]) { if(err) *err = [NSError errorWithDomain:SPErrorDomain code:1 userInfo:@{ NSLocalizedDescriptionKey: NSLocalizedString(@"Error while retrieving filter clause. No “NumberOfArguments” key found.", @"filter to sql conversion : internal error : invalid filter definition (1)"), }]; return; } if(![filter objectForKey:@"Clause"] || ![(NSString *)[filter objectForKey:@"Clause"] length]) { if(err) *err = [NSError errorWithDomain:SPErrorDomain code:2 userInfo:@{ NSLocalizedDescriptionKey: NSLocalizedString(@"Content Filter clause is empty.", @"filter to sql conversion : internal error : invalid filter definition (2)"), }]; return; } NSArray *values = [in objectForKey:SerFilterExprValues]; SPTableFilterParser *parser = [[SPTableFilterParser alloc] initWithFilterClause:[filter objectForKey:@"Clause"] numberOfArguments:[[filter objectForKey:@"NumberOfArguments"] integerValue]]; [parser setArgument:[values objectOrNilAtIndex:0]]; [parser setFirstBetweenArgument:[values objectOrNilAtIndex:0]]; [parser setSecondBetweenArgument:[values objectOrNilAtIndex:1]]; [parser setSuppressLeadingTablePlaceholder:[[filter objectForKey:@"SuppressLeadingFieldPlaceholder"] boolValue]]; [parser setCaseSensitive:isBINARY]; [parser setCurrentField:[in objectForKey:SerFilterExprColumn]]; NSString *sql = [parser filterString]; // SPTableFilterParser will return nil if it doesn't like the arguments and NSMutableString doesn't like nil if(!sql) { if(err) *err = [NSError errorWithDomain:SPErrorDomain code:3 userInfo:@{ NSLocalizedDescriptionKey: NSLocalizedString(@"No valid SQL expression could be generated. Make sure that you have filled in all required fields.", @"filter to sql conversion : internal error : SPTableFilterParser failed"), }]; [parser release]; return; } [out appendString:sql]; [parser release]; } if(wrap) [out appendString:@")"]; } @end #pragma mark - @implementation RuleNode @synthesize type = type; - (NSUInteger)hash { return type; } - (BOOL)isEqual:(id)other { if (other == self) return YES; if (other && [[other class] isEqual:[self class]] && [(RuleNode *)other type] == type) return YES; return NO; } @end @implementation ColumnNode @synthesize name = name; @synthesize typegrouping = typegrouping; @synthesize operatorCache = operatorCache; @synthesize opCacheVersion = opCacheVersion; - (instancetype)init { if((self = [super init])) { type = RuleNodeTypeColumn; opCacheVersion = 0; } return self; } - (NSString *)description { return [NSString stringWithFormat:@"ColumnNode<%@@%p>",[self name],self]; } - (NSUInteger)hash { return ([name hash] ^ [typegrouping hash] ^ [super hash]); } - (BOOL)isEqual:(id)other { if (other == self) return YES; if (other && [[other class] isEqual:[self class]] && [name isEqualToString:[other name]] && [typegrouping isEqualToString:[other typegrouping]]) return YES; return NO; } @end @implementation StringNode @synthesize value = value; - (instancetype)init { if((self = [super init])) { type = RuleNodeTypeString; } return self; } - (NSUInteger)hash { return ([value hash] ^ [super hash]); } - (BOOL)isEqual:(id)other { if (other == self) return YES; if (other && [[other class] isEqual:[self class]] && [value isEqualToString:[(StringNode *)other value]]) return YES; return NO; } @end @implementation OpNode @synthesize parentColumn = parentColumn; @synthesize settings = settings; @synthesize filter = filter; - (instancetype)init { if((self = [super init])) { type = RuleNodeTypeOperator; } return self; } - (void)dealloc { [self setFilter:nil]; [self setSettings:nil]; [super dealloc]; } - (NSUInteger)hash { return (([parentColumn hash] << 16) ^ [settings hash] ^ [super hash]); } - (BOOL)isEqual:(id)other { if (other == self) return YES; if (other && [[other class] isEqual:[self class]] && [settings isEqualToDictionary:[(OpNode *)other settings]] && [parentColumn isEqual:[other parentColumn]]) return YES; return NO; } @end @implementation ArgNode @synthesize filter = filter; @synthesize argIndex = argIndex; @synthesize initialValue = initialValue; - (instancetype)init { if((self = [super init])) { type = RuleNodeTypeArgument; } return self; } - (void)dealloc { [self setInitialValue:nil]; [self setFilter:nil]; [super dealloc]; } - (NSUInteger)hash { // initialValue does not count towards hash because two Args are not different if only the initialValue differs return ((argIndex << 16) ^ [filter hash] ^ [super hash]); } - (BOOL)isEqual:(id)other { // initialValue does not count towards isEqual: because two Args are not different if only the initialValue differs if (other == self) return YES; if (other && [[other class] isEqual:[self class]] && [filter isEqualToDictionary:[(ArgNode *)other filter]] && argIndex == [(ArgNode *)other argIndex]) return YES; return NO; } @end @implementation ConnectorNode @synthesize filter = filter; @synthesize labelIndex = labelIndex; - (instancetype)init { if((self = [super init])) { type = RuleNodeTypeConnector; } return self; } - (void)dealloc { [self setFilter:nil]; [super dealloc]; } - (NSUInteger)hash { return ((labelIndex << 16) ^ [filter hash] ^ [super hash]); } - (BOOL)isEqual:(id)other { if (other == self) return YES; if (other && [[other class] isEqual:[self class]] && [filter isEqualToDictionary:[(ConnectorNode *)other filter]] && labelIndex == [(ConnectorNode *)other labelIndex]) return YES; return NO; } @end #pragma mark - @implementation ModelContainer @synthesize model = model; - (instancetype)init { if (self = [super init]) { model = [[NSMutableArray alloc] init]; } return self; } - (void)dealloc { [self setModel:nil]; [super dealloc]; } // KVO - (void)insertObject:(id)obj inModelAtIndex:(NSUInteger)idx { [model insertObject:obj atIndex:idx]; } - (void)removeObjectFromModelAtIndex:(NSUInteger)idx { [model removeObjectAtIndex:idx]; } - (void)insertModel:(NSArray *)array atIndexes:(NSIndexSet *)indexes { [model insertObjects:array atIndexes:indexes]; } - (void)removeModelAtIndexes:(NSIndexSet *)indexes { [model removeObjectsAtIndexes:indexes]; } @end