Недавно я работал с NSPredicate — API, который существует с момента выхода Mac OS X Tiger в 2005 году — и в довольно простой ситуации на самом деле все оказалось не так, как я ожидал.

Я имплементировал поддержку Apple Shortcuts в свое приложение для чтения, чтобы пользователи могли создавать автоматизированные рабочие процессы и заметил, что некоторые запросы статьи на основе свойств с использованием EntityPropertyQuery не выдавали их ожидаемое количество. У меня было четырнадцать статей, сохраненных на симуляторе iPad. Четыре из них были написаны лично мной. Однако когда я осуществлял поиск статей, автором которых был не "Дуглас Хилл (Douglas Hill)", получилось всего два результата вместо ожидаемых десяти.

Стало ясно, что статьи не включились в режим поиска, когда автор не был задан. Другими словами, когда свойство author равнялось nil. (В этой статье я буду смешивать термины nil и null, потому что они представляют одну и ту же концепцию с различными названиями в разных программных стеках).

Отслеживание проблемы

Во-первых, давайте рассмотрим самый простой тест:

let maybeString: String? = nil
let condition = maybeString != "test"

Ожидается, что condition в этом случае  будет истинным. Если бы мы использовали ==, то результат явно был бы ложным. Однако здесь применяется !=, поэтому мы предполагаем обратное. Хорошие новости: все действительно так и работает!

Во-вторых, я запустил быстрый тест на плейграунде, используя NSPredicate в простой ситуации:

class MyObject: NSObject {
    @objc var author: String?
    init(author: String?) {
        self.author = author
    }
}

let array = [
    MyObject(author: "Douglas Hill"),
    MyObject(author: "Someone else"),
    MyObject(author: nil),
]

(array as NSArray).filtered(using: NSPredicate(format: "author != %@", "Douglas Hill"))
// [{NSObject, author "Someone else"}, {NSObject, nil}]

Он показал, что в результате фильтрации объектов, автором которых является не "Douglas Hill", были включены объекты, где автором являлся “nil”. Именно такого поведения я и ожидал.

В этот момент у меня возникло сильное подозрение, что это связано с хранилищем SQLite, которое использует мой стек Core Data. Не сомневаюсь, на этот вопрос ветераны SQL уже знают ответ.

В-третьих, я выполнил отладку с моим хранилищем Core Data без использования Shortcuts и увидел то же самое, что и вместе с ним: фильтрация по атрибуту, не равному определенному значению, не включает объекты, у которых этот атрибут равен nil.

Я включил -com.apple.CoreData.SQLDebug 3, и это продемонстрировало простоту генерируемых SQL-команд. Данный предикат: author != "Douglas Hill" добавит нижеследующее в команду SQL SELECT:

WHERE  t0.ZAUTHOR <> ?

Где значением ? является:

SQLite bind[0] = "Douglas Hill"

Я никогда не работал с SQL напрямую, а только как с составляющей имплементации (и производительности) Core Data. На данный момент моя гипотеза заключалась в том, что такая обработка null — это просто то, как работает SQL.

К сожалению, SQL, похоже, не является бесплатным и открытым стандартом, где вы можете легко прочитать ссылку/спецификацию, чтобы проверить данное обстоятельство. Я провел небольшое исследование в Интернете, и дополнительные источники подтвердили мою гипотезу. NULL не считается равным или неравным чему-либо в SQL, или, другими словами, сравнения с null не являются ни истинными, ни ложными.

Этот комментарий jsumrall к вопросу о переполнении стека хорошо подводит итог:

Следует также отметить, что поскольку != применяется для сравнения значений, то, выполнение чего-то вроде WHERE MyColumn != 'somevalue', не вернет записи NULL.

Что ожидает пользователь?

С точки зрения программиста, я бы не сказал, что тот или иной способ обработки null однозначно лучше. Однако от NSPredicate я бы ожидал стабильности. Меня удивляет то, что Core Data не сглаживает такое поведение SQL, в соответствии с тем, как сравнения обычно работают в программных стеках Apple.

С точки зрения пользователя, мне кажется, ситуация иная. Пользователи не будут так же четко осознавать смысл понятия null. Вероятно, они считают, что null и пустая строка — это одно и то же. Поскольку мои запросы будут доступны пользователям через Shortcuts, полагаю, более ожидаемым будет то, что фильтрация элементов со свойством, не равным некоторому значению, должна включать элементы, у которых это свойство равно null.

Имплементация наилучшего поведения

Эту недоработку легко исправить самостоятельно. Настраивая предикат для хранилища Core Data SQLite с условием не быть равным некоторому значению. Не задавайте предикат следующим образом:

NSPredicate(format: "%K != %@", stringKey, nonNilValue)

Вместо этого мы также проверяем равенство с nil/null, задавая предикат таким образом:

NSPredicate(format: "%K != %@ OR %K == NIL", stringKey, nonNilValue, stringKey)

На практике это выглядит как вспомогательное расширение NotEqualToComparator из фреймворка Apple App Intents (API Shortcuts):

private extension NotEqualToComparator<EntityProperty<String?>, String?, NSPredicate> {
    /// Creates a comparator for case- and diacritic-insensitive matching of an optional string property using an NSPredicate for Core Data objects. (My objects are articles.)
    convenience init(keyPath: KeyPath<ArticleEntity, EntityProperty<String?>>) {
        // Maps from Swift key paths to string keys.
        let stringKey = Article.stringKey(from: keyPath)
        self.init() { value in
            if let value {
                return NSPredicate(format: "%K !=[cd] %@ OR %K == NIL", stringKey, value, stringKey)
            } else {
                // Ignore this branch for now since Shortcuts doesn’t have any UI that lets a nil value be passed here. My actual code is slightly different due to an interesting reason, but that’s not the topic of this article.
            }
        }
    }
}

Резюме

  • В Swift результат nil != nonNilValue является истинным.

  • Как правило, предикат NSPredicate, созданный в виде NSPredicate(format: "%K != %@", stringKey, nonNilValue), будет соответствовать объектам, у которых свойство, соответствующее stringKey, равно nil.

  • При извлечении из хранилища Core Data SQLite предикат, созданный как описано выше, не будет совпадать с объектами, у которых свойство, соответствующее stringKey, равно nil. Это происходит потому, что Core Data напрямую мапирует команду с SQL, а SQL определяет, что не существует значения, равного или неравного null.

  • Это можно обойти, создав предикат так: NSPredicate(format: "%K != %@ OR %K == NIL", stringKey, nonNilValue, stringKey).

  • Урок: Проверяйте все и всегда.


Приглашаем всех желающих на открытое занятие "Создание приложения таймер". На этом уроке создадим простое приложение, посмотрим, как за небольшой отрезок времени написать готовое решение и использовать его в повседневной жизни. Записаться можно по ссылке.

Комментарии (0)