2
\$\begingroup\$

I have a standard tableview app that displays information from a data model array that uses a custom class. There is a UISearchController to filter the data.

A simplified version of the custom class is shown. I use a computed property for a string that is checked when searching the data.

class myObject: NSObject {

    // Inherits from NSObject as I use NSCoding for persistence

    var id: String = UUID().uuidString // These are unique for each item

    var name: String = ""
    var type: String = ""
    var batch: String = ""

    var searchableString: String {
        // This is a concatenation of some of the properties
        return self.name + " " + self.type + " " + self.batch
    }

    // etc...
}

Every time the user types a new character into the search bar of the UISearchController, the following method is called, which filters the main data array into a filteredItems array. Initially, I used a very basic form where the entire search text had to match.

func searchItems() {
    if let searchText = searchController.searchBar.text {
        filteredItems = allItems.filter { item in
            return item.searchableString.lowercased().contains(searchText.lowercased())
        }
    }
}

filteredItems is then displayed in the tableview.

I wanted to improve this so that each word typed in the search bar is searched for separately. I tried this, but of course it filters using OR logic (any word can match).

func searchItems() {
    if let searchText = searchController.searchBar.text?.split(separator: " ") {
        // Need to convert substring from split back to string
        let searchArray = searchText.map() { substring in
            String(substring)
        }
        filteredItems = allItems.filter { item in
            return searchArray.contains(where: drug.searchableString.lowercased().contains)
        }
    }
}

In order to get the desired AND logic (every word typed must match), I had to use this.

func searchItems() {
    if let searchText = searchController.searchBar.text?.split(separator: " ") {
        // Need to convert substring from split back to string
        let searchArray = searchText.map() { substring in
            String(substring)
        }
        filteredItems = allItems.filter { item in
            for word in searchArray {
                if !item.searchableString.lowercased().contains(word) {
                    return false
                }
            }
            return true
        }
    }
}

This works as intended, but it does not seem elegant. Is there a better way to filter the array allItems to filteredItems based on matching text in those String properties of the objects in my data model?

\$\endgroup\$
  • 1
    \$\begingroup\$ Using searchableString is a bad design. searchItems() is not pure/honest. The filter closure could be written as .filter { item in return searchArray.allSatisfy({ item.searchableString.lowercased().contains($0) }) }. Either way, it's far from being efficient. \$\endgroup\$ – ielyamani Jan 28 at 20:31
  • \$\begingroup\$ @ielyamani Thanks for the advice. I suspected it was not a good maintainable or extensible design. \$\endgroup\$ – Chris Feb 1 at 21:00
2
+50
\$\begingroup\$

Charles is completely right, regex is the best solution for such task.

First I would define extension for String to match regex

extension String {

  func match(regex: String) -> Bool {
    return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: self)
  }
}

Then your search function can be done like this:

// OR case
func searchItems() -> [Model] {
  guard let searchWords = searchController.searchBar.text?.split(separator: " ") {
    return [allItems] // Or you can return empty array, depending on your requirements
  }
  // .* = any characters
  // (?i) = case insensitive
  // (word1|word2) = words to search using OR
  // .* = any characters
  let searchRegex = ".*(?i)(\(searchWords.joined(separator: "|"))).*"
  return allItems.filter { $0.drug.match(regex: searchRegex) }
}

// AND case
func searchItems() -> [Model] {
  guard let searchWords = searchController.searchBar.text?.split(separator: " ") {
    return [allItems] // Or you can return empty array, depending on your requirements
  }

  let mappedSearchWords = searchWords.map { "(?=.*\($0))" }
  // .* = any characters
  // (?i) = case insensitive
  // (?=.*word1)(?=.*word2)) = words to search using AND
  // .* = any characters
  let searchRegex = ".*(?i)" + mappedSearchWords.joined() + ".*"
  return allItems.filter { $0.drug.match(regex: searchRegex) }
}

// My general recommendation is to declare search function with search text and items as input
// Then it can be easily used for testing
// OR case
func searchAnyWords(from text: String, in items: [Model]) -> [Model]
// AND case
func searchAllWords(from text: String, in items: [Model]) -> [Model]
| improve this answer | |
\$\endgroup\$
2
\$\begingroup\$

You have picked a hard data structure and hard algorithm to solve it. Specifically, you have your searchable list as a list of strings and need to parse and convert it for each search. You then do linear searches.

If you are going to precompute or cache your search information, consider creating a dictionary where the keys are the words and the values are sets of item indices. Then you search is anding the set of indices with the value of each word.

Alternately, if you are optimizing for code brevity, use a regular expression. If your search strings were like ':MyName:MyType:MyBatch:' You want to create an expression from your search like :(word1|word2|word3): and use

NSUInteger numberOfMatches = [regex numberOfMatchesInString:string
                                                    options:0
                                                      range:NSMakeRange(0, [string length])];

and make sure it matches the number of words. Yes, more work if you want to avoid the, er, inventive user, searching for "MyBatch MyBatch MyBatch".

Keep trying to make it prettier :)

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ Many thanks for your answer - I hadn’t considered regular expressions. \$\endgroup\$ – Chris Feb 1 at 21:47
  • 2
    \$\begingroup\$ Let me recommend regex101.com as a resource for creating and testing the regular expressions. It always saves me a few cycles. \$\endgroup\$ – Charles Merriam Feb 2 at 0:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.