В обычном поиске пустой список может означать две разные вещи. Либо подходящих объектов нет, либо в выбранном наборе объектов вообще нет свойства, по которому мы пытаемся искать. Обычно обе ситуации заканчиваются [], и дальше программа сама должна догадываться, что произошло.
Я предлагаю различать их дополнительным состоянием результата - NID (Not In Domain). По смыслу примерно как null позволил не обозначать отсутствие значения каким-нибудь специальным числом или угадывать "что здесь значит 0". NID не заменяет пустой массив, а сообщает о другой причине отсутствия результата.
Вторая часть идеи - описывать объекты набором независимо присутствующих фактов, в том числе отрицательных. С NID она хорошо сочетается, но не является обязательной. И наоборот: наборы фактов могут работать вообще без NID.
Когда отсутствует само свойство
Допустим, каталог содержит цифровые игры и несколько настольных. У настольных игр есть материал поля - дерево или пластик, у цифровых этого свойства нет. Если настольные игры закончились и в наличии остались только цифровые, запрос boardMaterial = wood ничего не найдёт. Но ведь деревянных полей не просто нет. Среди доступных игр вообще нет объектов, для которых имеет смысл проверять материал поля.
Можно вернуть обычное []. Только тогда интерфейс не отличит "нет игр с деревянным полем" от "материала поля здесь вообще не бывает". Предлагается третий ответ:
{
"items": [],
"result": "nid",
"missingFacts": ["hasMaterial"]
}
Если в каталоге появится настольная игра с пластиковым полем, свойство материала поля снова встретится. Запрос дерева теперь вернёт [], если деревянных полей по-прежнему нет. NID относится к конкретной области поиска, а не объявляет, что материала никогда и нигде не существует.
Таким образом, состояния различаются:
items: [...]- совпадения найдены;items: []- поиск применим, но совпадений нет;result: "nid"- один или несколько запрошенных фактов вообще не встречаются в рассматриваемой области;nullи ошибка - отдельные состояния со своим смыслом, зависящим от API.
NID - рабочее название и предлагаемая семантика результата, не новый логический оператор и не стандарт языка. Если автор системы предпочитает обрабатывать подобное через пустой массив, никто ему не мешает. Просто часть информации тогда теряется.
Объект как набор фактов
Обычный объект принято описывать значениями полей:
{
"name": "Konor",
"eyeColor": "blue",
"type": "human"
}
Но можно описывать и через наличие отдельных утверждений:
{
"name": "Konor",
"facts": [
"isHuman",
"isNotHuman",
"eyesIsBlue",
"eyesNotGreen"
]
}
Каждый факт либо присутствует, либо отсутствует. В PHP это можно представить набором строк, в типизированном языке - необязательным полем со значением только true. false не требуется, потому что отсутствие положительного факта ещё не означает наличие отрицательного.
eyesNotBlue здесь не вычисляется из eyesIsGreen или eyesIsBrown. Это самостоятельное утверждение, которое автор зачем-то посчитал полезным. Может быть, ему важно искать именно несинеглазых, не определяя все остальные цвета. Факт можно добавить, удалить и проверить независимо от других.
То же касается hasVideo и noVideo. Отсутствие hasVideo ещё не означает, что видео точно нет. Если мы знаем об отсутствии видео, можно записать noVideo. Или не писать ничего, если состояние неизвестно. Это легко применить к интерфейсу: у администратора сделать фильтрацию по "есть видео" и "нет видео". У пользователя только по "есть видео". Врятли пользователю нужны товары где точно нет видео. Администратору же, пригодится список таких товаров для актуализации контента.
Логика не обязана быть согласованной
Сочетание isHuman и isNotHuman выглядит противоречивым, если считать второй факт строгим логическим отрицанием первого. Не обязательно. Персонаж может быть вервольфом. На него действуют способности против людей, и на него же действуют способности против нелюдей. Достаточно проверить присутствие соответствующего факта, не добавляя специальную проверку на вервольфа в каждую способность.
С глазами то же самое. eyesIsBlue и eyesNotBlue могут означать, что один глаз синий, второй нет. Или глаз двухцветный. Редактор персонажей может отрисовать подобное совершенно корректно. Одного набора фактов для точной отрисовки, возможно, не хватит, но правила интерпретации - уже задача редактора.
При желании можно записать даже заведомо невозможное состояние. Система хранения не должна исправлять его только потому, что кто-то не придумал правдоподобной интерпретации. Если для конкретного проекта нужна проверка совместимости, её можно добавить отдельно.
Технически здесь нет требования принять какую-то особую логику. Есть утверждения, есть поиск по их наличию, а смысл и ограничения задаёт приложение. Необычная комбинация фактов сама по себе не ошибка. Появляется "внешний арбитр реализации логики", что само по себе большая новая возможность.
Несколько фактов в одном запросе
Несколько фактов из запроса могут отсутствовать, хорошо бы показать все сразу, не останавливаться на первом встреченном. Пусть запрос требует одновременно fact1, fact2 и fact3. В текущем каталоге первый факт встречается, а второго и третьего нет вообще. Тогда NID может сразу сообщить, чего именно не хватает:
{
"items": [],
"result": "nid",
"missingFacts": ["fact2", "fact3"]
}
Проверка каждого факта выполняется независимо, в одной области поиска. NID здесь не означает, что совместное сочетание невозможно. Он означает только отсутствие отдельных запрошенных фактов в рассматриваемом наборе.
Кажется, что если фильтруем только по фактам, в результате будет только непустой результат и nid, но это не так. Можно получить пустой результат, если все факты существуют у каких-то записей, но нет записи со всеми фактами одновременно.
Уже из одного ответа API можно показать, какие фильтры не сработают в принципе, а какие просто не дали совпадений. Для сложного поиска с десятком условий это полезнее общего сообщения "ничего не найдено".
Что это даёт в обычных проектах
В каталоге игр можно помечать фильтр по материалу поля: "У доступных игр нет свойства материал поля". Не скрывать его молча и не показывать ноль игр без объяснения. Когда появится настольная игра, состояние изменится. Интерфейс может строить набор доступных фильтров из реально присутствующих фактов, а не из жёстко прописанной таблицы категорий.
В редакторе персонажей можно хранить независимые теги, собирать необычные сочетания, назначать способности и искать нужные комбинации без развесистых условий по типам существ. isHuman и isNotHuman одновременно не требуют нового типа werewolf, хотя отдельный факт isWerewolf тоже можно добавить, если он нужен для других правил.
В поисковых API NID позволяет вернуть причину пустого ответа прямо вместе с результатом. Клиент может убрать бесполезное условие, подсветить его или предложить изменить область поиска. Не нужно повторно запрашивать схему или пытаться угадывать по пустому массиву: сразу понятно, что любой запрос с отсутствующим для всех фактом не вернёт ничего.
В отчётах это помогает не путать нулевое значение измерения с отсутствием самого измеряемого свойства. Например, сумма по существующему полю может быть равна нулю, а у некоторой категории объектов поля вообще нет. Мы сразу понимаем, что у нас не "ноль записей со свойством", а "нет записей со свойством". Как именно обрабатывать NID при агрегации, решает приложение, но различие уже не потеряно.
Есть и польза для диагностики. Если вчера запрос возвращал записи, а сегодня NID, значит из текущей области исчезли все носители одного из запрошенных фактов. Возможно, из каталога исчезли настольные игры, сменился источник данных или сломался импорт. NID не объясняет причину, но позволяет обнаружить такое изменение отдельно от обычного отсутствия совпадений.
Порядок операторов имеет значение
NID особенно полезен, если проверка фактов выполняется до обычных условий. Например:
User::facts(['isHuman', 'isMage'])
->where('level', '>', 50)
->get();
Сначала выясняем, встречаются ли isHuman и isMage среди пользователей вообще. Если какого-то факта нет, получаем NID. Если оба встречаются, ищем пользователей, обладающих ими одновременно и имеющих уровень выше 50. Пустота от несовпадения значений остаётся обычным [].
Но порядок можно поменять:
User::where('level', '>', 50)
->facts(['isHuman', 'isMage'])
->get();
Теперь факты проверяются только среди пользователей выше 50-го уровня. Такой NID менее общий, зато может быть полезен для диагностики конкретной выборки. Комбинации операторов не обязаны иметь одинаковую ценность, но и запрещать их незачем. Можно даже указывать заведомо невозможные условия. Обычный поиск вернёт [], если дело в значениях, а не в отсутствующих фактах.
Для последовательных facts() важна ещё одна деталь. На каждом этапе проверяем запрошенные факты в текущей области до применения всех фактов этого этапа. Если область уже пуста из-за предшествующих условий, возвращаем обычный пустой результат: из пустой выборки невозможно сделать вывод, что какие-то факты отсутствуют во всём каталоге.
Фильтр коллекций
Тот же результат можно получить без базы, если объекты уже лежат в массиве. NidObjectFilter принимает объекты с полем facts, сначала ищет каждый запрошенный факт во всём массиве, а затем оставляет только объекты со всеми фактами.
require 'NidObjectFilter.php';
$objects = [
['name' => 'Konor', 'facts' => ['isHuman', 'isMage']],
['name' => 'Dor', 'facts' => ['isOrc']],
];
// Оба факта существуют, но не у одного объекта: обычный пустой результат.
$result = NidObjectFilter::filter($objects, ['isHuman', 'isOrc']);
// Такого факта нет ни у одного объекта: result будет "nid".
$result = NidObjectFilter::filter($objects, ['hasMaterial']);
Это тот же контракт ответа: items, result и missingFacts. Поэтому фильтр массива и SQL-запрос можно менять местами, не меняя обработку результата.
SQL и PHP
Новый язык запросов здесь не нужен. Можно хранить данные в обычных таблицах users и user_facts(user_id, fact). Для фактов достаточно уникальной пары (user_id, fact), а для поиска по факту полезен индекс (fact, user_id).
Генератор сначала проверяет наличие каждого запрошенного факта в текущей области. Например, на первом этапе:
SELECT DISTINCT f.fact
FROM users u
JOIN user_facts f ON f.user_id = u.id
WHERE f.fact IN (?, ?);
Если все факты найдены, к основному запросу добавляются условия EXISTS по каждому факту. Если часть не найдена, собирается missingFacts, а основной поиск можно не запускать. Для facts() после where() проверочный SQL уже учитывает предшествующие условия. В Go NID можно передавать через обычную пару result, err как типизированную ошибку. В PHP удобнее вернуть массив или объект результата. Состояние одно и то же, новый язык не нужен.
Реализация на чистом PHP собрана в архив NID. Для SQL-части нужен PHP с расширением PDO SQLite.
php demo.php
Скрипт сам создаст nid-demo.sqlite, загрузит схему и тестовые данные, выведет обе таблицы, проверит несколько случаев NID и порядка операторов.
В настоящем проекте библиотеку можно встроить в существующий Query Builder. В Laravel не нужно создавать новое соединение: берём PDO из модели, поэтому запрос использует обычные настройки подключения Laravel. Модель остаётся в приложении, поэтому ей известны таблица и разрешённые поля:
use App\Models\User;
require_once app_path('Support/NidQuery.php');
$user = new User;
$query = new NidQuery(
$user->getConnection()->getPdo(),
table: $user->getTable(),
factsTable: 'user_facts',
fields: ['name', 'level', 'status'],
);
$result = $query
->facts(['isHuman', 'isNotHuman'])
->where('name', '=', 'Konor')
->get();
// Здесь область NID уже ограничена пользователями выше 50-го уровня.
$result = (new NidQuery($user->getConnection()->getPdo(), $user->getTable(), 'user_facts', ['name', 'level', 'status']))
->where('level', '>', 50)
->facts(['isHuman', 'isMage'])
->get();
Итог
Разобрано различение состояний "нет результатов" от "такой поиск не имеет смысла". Как когда-то начали отличать 0 от null. На самом деле люди поняли null намного раньше, теперь почему-то наоборот, понимающих null куда меньше, чем 0.
Добавлено новое состояние результата nid наряду с null, None, undefined, void и множеством других способов показать разные виды отсутствия. Результат получился не академическим, не требующим "нового языка с поддержкой оператора nid". Неплохо, но имеем что имеем. Достаточно полно рассмотрена реализация на PHP, встраивание в Laravel. Есть заметки, как сделать аналогичное на Go.
Как возникла идея
Под утро не спалось. Думал, какой тип результата у подсчёта количества единорогов за шторой. Неверно считать их за 0. null тоже не подходит. Их там вообще не может быть по причине отсутствия сущности "единорог" в нашем мире. Дошёл до способа задать новый вид результата: "не может быть".
Если точнее, то "вне системы". А про системы начал думать, когда разбирал, откуда берётся потенциальная энергия у объектов в системе, если мы самим фактом включения объекта можем "добавлять" энергию в систему. Новый объект получает ещё энергию от других объектов системы из-за гравитации.