Перейти к содержимому
Поиск
Php Разработка

PHP: полный мануал с примерами, функциями и ООП

Практический мануал PHP: от переменных и читаемого кода до безопасных форм, интеграций, базы данных и ООП на примере магазина. Учебный маршрут, поиск функций, подробные примеры, разбор ошибок и исходники для скачивания.

Александр НекрасовИзменено:
Практический мануал PHP: от переменных и функций к объектной модели интернет-магазина
PHP 8.5.11468 глав6256 карточек справочника
Дерево руководства

Глава 117 · После таблиц, SELECT и параметров PDO

MySQL: полнотекстовый поиск от первого слова до страницы статей

Настоящий FULLTEXT-индекс, MATCH/AGAINST, релевантность, обязательные слова и фразы, фильтры организации, стоп-слова и видимость после COMMIT.

Как поиск слов отличается от поиска подстроки

Посетитель базы знаний ищет материал словами: «отслеживание посылки». Задача — найти подходящие статьи и вернуть нужную страницу, а не забрать все тексты в PHP и перебирать их массивом. Здесь начнём с одного индексируемого слова parcel и постепенно добавим рейтинг, исключения, фразы, организацию и диагностику пустой выдачи.

Карта поискового действия
  1. INSERT сохраняет title и body статьи
  2. FULLTEXT составляет поисковый указатель слов
  3. Параметр поиска поступает в MATCH ... AGAINST
  4. WHERE оставляет подходящие и разрешённые строки
  5. ORDER BY и LIMIT формируют страницу; PHP только отображает
ЗадачаПодход
Точный номер заказа, артикулОтдельное поле, = и подходящий обычный индекс
Начало имени/кодаLIKE с осмысленным префиксом; см. соседнюю главу
Слова в заголовке и текстеFULLTEXT и MATCH ... AGAINST
Произвольная подстрокаLIKE/LOCATE с пониманием объёма просмотра
Морфология, исправление опечаток, сложные языкиОтдельные поисковые требования и подходящий анализатор; этот пример их не реализует

Таблица, строка, SELECT и параметры уже разобраны в основах схемы и подготовленных фильтрах. Здесь каждая новая поисковая запись объясняется до полной программы. Правила регистра и акцентов рассматриваются в сравнении текста. Термины в body намеренно короткие и английские, чтобы увидеть действие стандартного парсера; смысл задачи и результаты объяснены по-русски.

Запустить пример и увидеть таблицу результатов

Каждый ZIP самодостаточен: example.php, ft111-helper.php и README. Требуются PHP 8.4+ с PDO_MYSQL/mysqlnd, MySQL 8.4, локальная учебная база и пользователь. Настройка сервера и реквизитов дана в первом подключении. Установите PHPMAN_MYSQL_HOST, PHPMAN_MYSQL_PORT, PHPMAN_MYSQL_DATABASE, PHPMAN_MYSQL_USER и PHPMAN_MYSQL_PASSWORD в своём терминале; на стенде адрес 127.0.0.1:33070. Выполните php example.php и откройте result.html.

FULLTEXT не поддерживает TEMPORARY-таблицы. Поэтому программа создаёт свою обычную таблицу mysql111_ft_ с новым случайным суффиксом и удаляет только её в finally. CREATE без IF NOT EXISTS не позволяет случайно присвоить существующую таблицу. Используйте отдельную учебную базу с правами CREATE, SELECT, INSERT, DROP; индекс входит в CREATE TABLE. Настройки сервера и данные сайта не меняются. Аварийное завершение процесса может оставить новую таблицу: порядок точечной очистки описан в README.

Найти слово в заголовке и тексте статьи

В службе поддержки есть статьи об отслеживании, складе, возврате и оплате. Сотрудник вводит parcel — «посылка». Экран должен показать две подходящие статьи, даже если слово встречается в body, а не в видимом заголовке.

Исходные данные

IDЗаголовокbody
1Отследить посылкуparcel tracking
2Принять товарwarehouse inventory
3Оформить возвратparcel returns
4Оплатить заказinvoice payment
5Настроить доступaccount settings
Путь запроса и ответа
  1. Сохранённые title и body каждой статьи
  2. FULLTEXT ft_text разбирает текст на поисковые слова
  3. parcel → совпадения статей 1 и 3
  4. SELECT отдаёт только id и title
  5. PHP выводит две карточки; тело статей не передаётся

Что получится

ID статьиЗаголовок
1Отследить посылку
3Оформить возврат

Начните с этой записи SQL

CREATE TABLE articles (
    id INT PRIMARY KEY,
    title VARCHAR(150) NOT NULL,
    body TEXT NOT NULL,
    FULLTEXT KEY ft_text (title,body)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

SELECT id,title FROM articles
WHERE MATCH(title,body)
      AGAINST ('parcel' IN NATURAL LANGUAGE MODE)
ORDER BY id LIMIT 10;

Этот объясняющий фрагмент предполагает уже созданную таблицу articles и входные строки выше. В самостоятельной программе имя таблицы уникально, данные создаются автоматически, а поисковые значения передаются параметрами.

title — короткий заголовок VARCHAR, body — основной текст TEXT. FULLTEXT KEY ft_text(title,body) создаёт специальный поисковый индекс для этих двух колонок. MATCH(title,body) выбирает поисковую коллекцию, AGAINST задаёт запрос. Выражение возвращает числовую оценку; в WHERE нулевое значение отсекается. ORDER BY id делает порядок этого первого примера явным, LIMIT 10 ограничивает выдачу. Про обычные поля, ключи и таблицы напомнит начальная модель базы.

Индекс можно представить как указатель слов в книге: у слова parcel перечислены статьи 1 и 3. Это модель назначения, не точная схема хранения InnoDB. Текст сначала разделяется на токены; часть слов может не индексироваться. Поэтому поиск слова отличается от поиска любых букв внутри строки.

В MATCH пишут колонки, а не имя индекса ft_text. Один индекс (title,body) не заменяет отдельный индекс для MATCH(title). Полная программа намеренно пробует несовпадающий список, перехватывает ошибку MySQL 1191 и сохраняет код в metrics.json; затем штатно заканчивает работу. Для поиска только заголовков сначала нужен соответствующий FULLTEXT-индекс.

Границы и ошибки

В SQL-фрагменте articles — объясняющее имя; самостоятельный архив сам создаёт собственную таблицу и входные данные. FULLTEXT применим к CHAR/VARCHAR/TEXT в поддерживаемом движке; здесь выбран InnoDB и utf8mb4. Он не превращает поиск по SKU, точному номеру или произвольной подстроке в поиск слов. Тексты на английском выбраны для наглядных терминов и стандартных стоп-слов; заголовки и результат остаются русскими.

Полная самостоятельная программа и файлы
Найти слово в заголовке и тексте статьиСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/ft111-helper.php';
$db=ft111Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$table='mysql111_ft_'.bin2hex(random_bytes(8));
$created=false;
$details=[];
try {
    ft111Create($db,$table);
    $created=true;
ft111Seed($db,$table,[
        [1,7,'published','Отследить посылку','parcel tracking'],
        [2,7,'published','Принять товар','warehouse inventory'],
        [3,7,'published','Оформить возврат','parcel returns'],
        [4,7,'published','Оплатить заказ','invoice payment'],
        [5,7,'published','Настроить доступ','account settings']
    ]);
    $found=ft111Rows($db,'SELECT id,title FROM '.ft111Name($table).' WHERE MATCH(title,body) AGAINST (? IN NATURAL LANGUAGE MODE) ORDER BY id LIMIT 10',['parcel']);
    $rows=[];foreach($found as $row){$rows[]=[(int)$row['id'],$row['title']];}
    $wrongColumns=null;
    try {ft111Rows($db,'SELECT id FROM '.ft111Name($table).' WHERE MATCH(title) AGAINST (?)',['parcel']);}
    catch(PDOException $error){if((int)($error->errorInfo[1]??0)!==1191){throw $error;}$wrongColumns=['sqlstate'=>$error->errorInfo[0],'driver_code'=>(int)$error->errorInfo[1]];}
    $checks=['exact_article_ids'=>ft111Ids($found)===[1,3],'wrong_column_list_rejected'=>$wrongColumns!==null && $wrongColumns['driver_code']===1191];
    $details=['input_query'=>'parcel','wrong_column_list'=>$wrongColumns,'matched_column_list'=>['title','body']];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    if($created){$db->exec('DROP TABLE '.ft111Name($table));}
}
$remaining=ft111Rows($db,'SELECT COUNT(*) AS remaining_tables FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?',[$table]);
$checks['own_fixture_removed']=(int)$remaining[0]['remaining_tables']===0;
$db=null;
ft111Report('mysql111-fulltext-first-index', ['ID статьи', 'Заголовок'], $rows, $checks, $runtime, $details);
echo 'mysql111-fulltext-first-index: saved result.html and result.json
';
Ожидаемый результат
mysql111-fulltext-first-index: saved result.html and result.json

Запустите один архив и откройте result.html: он содержит конкретную таблицу выше. Подключение, создание данных и ft111-helper.php уже включены; запуск предыдущих примеров не требуется.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Добавьте в данные статью 6 с body="parcel insurance". Ожидаемый ответ: ID 1, 3, 6. Затем поменяйте SELECT на id,title,body и объясните, зачем экрану понадобилось дополнительное поле; если не понадобилось — оставьте явные два поля.

Практический вопрос: Что писать в MATCH: имя индекса или список колонок. Автор вопроса создал индекс на несколько колонок и пытался передать его имя вместо списка. Наш самостоятельный пример показывает корректную коллекцию title/body и отдельно фиксирует отказ несовпадающего списка. Ситуация взята из обсуждения; правила сверены с первичными источниками, код и учебные данные написаны отдельно.

Показать подходящие статьи в явном порядке

Теперь совпадений больше, и первой нужна статья, лучше соответствующая слову parcel. Покажем отдельно три действия: оценить совпадение, оставить подходящие строки и расположить их по оценке.

Исходные данные

IDЗаголовокbody
1Отслеживаниеparcel parcel tracking
2Возвратparcel return
3Складwarehouse shelving
4Платёжinvoice payment
5Профильaccount profile
Путь запроса и ответа
  1. Параметр parcel передаётся в два места SQL
  2. MATCH вычисляет score для каждой подходящей статьи
  3. WHERE исключает нулевые совпадения
  4. ORDER BY score DESC, id ASC задаёт выдачу
  5. LIMIT 3 → в наших данных только две карточки

Что получится

МестоID статьиЗаголовок
11Отслеживание
22Возврат

Начните с этой записи SQL

SELECT id,title,
       MATCH(title,body) AGAINST (? IN NATURAL LANGUAGE MODE) AS score
FROM articles
WHERE MATCH(title,body) AGAINST (? IN NATURAL LANGUAGE MODE)>0
ORDER BY score DESC,id ASC
LIMIT 3;

Этот объясняющий фрагмент предполагает уже созданную таблицу articles и входные строки выше. В самостоятельной программе имя таблицы уникально, данные создаются автоматически, а поисковые значения передаются параметрами.

AS score даёт вычисленному столбцу имя. DESC означает сначала большие оценки; ASC у id разрешает равенство оценок одинаковым порядком. WHERE отвечает за включение строки, ORDER BY — за её место; одно не заменяет другое. В маленьком наборе статья 1 повторяет parcel дважды и стоит перед статьёй 2. Полная программа сохраняет фактические численные score в metrics.json, а в таблице результата показывает проверяемые места 1 и 2.

Релевантность — оценка относительно данного набора текстов, не вероятность правильного ответа и не процент похожести. Пополнение коллекции может изменить числа и порядок. Не закрепляйте в тесте случайный десятичный score как вечную константу. Здесь проверены положительные значения, убывающий порядок и конкретные статьи на фиксированных входах.

В SQL два знака ?: один в SELECT и один в WHERE. В execute передаём [$query,$query] в этом порядке; это два параметра одного значения. Маркер не заключают в кавычки. Контракт параметров PDO объясняет, почему нельзя использовать один маркер для имени колонки. Boolean mode тоже требует явного ORDER BY, если нужен рейтинг.

Границы и ошибки

Частое слово не обязательно важно для человека. Повторение текста не делает статью качественнее: это специально малый пример механизма. Для реального поиска проверяют характерные запросы посетителей, опечатки, нужные языки и правила ранжирования. LIMIT сокращает ответ клиенту, но не доказывает, что сервер прочитал ровно три записи. Эта программа не является замером скорости.

Полная самостоятельная программа и файлы
Показать подходящие статьи в явном порядкеСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/ft111-helper.php';
$db=ft111Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$table='mysql111_ft_'.bin2hex(random_bytes(8));
$created=false;
$details=[];
try {
    ft111Create($db,$table);
    $created=true;
ft111Seed($db,$table,[
        [1,7,'published','Отслеживание','parcel parcel tracking'],
        [2,7,'published','Возврат','parcel return'],
        [3,7,'published','Склад','warehouse shelving'],
        [4,7,'published','Платёж','invoice payment'],
        [5,7,'published','Профиль','account profile']
    ]);
    $query='parcel';
    $found=ft111Rows($db,'SELECT id,title,MATCH(title,body) AGAINST (? IN NATURAL LANGUAGE MODE) AS score FROM '.ft111Name($table).' WHERE MATCH(title,body) AGAINST (? IN NATURAL LANGUAGE MODE)>0 ORDER BY score DESC,id ASC LIMIT 3',[$query,$query]);
    $rows=[];$scores=[];
    foreach($found as $position=>$row){$rows[]=[$position+1,(int)$row['id'],$row['title']];$scores[]=(float)$row['score'];}
    $checks=['ranked_ids'=>ft111Ids($found)===[1,2],'positive_descending_score'=>count($scores)===2 && $scores[0]>$scores[1] && $scores[1]>0.0,'bounded_result'=>count($found)<=3];
    $details=['query'=>$query,'actual_scores'=>$scores,'score_is_not_probability'=>true,'limit'=>3];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    if($created){$db->exec('DROP TABLE '.ft111Name($table));}
}
$remaining=ft111Rows($db,'SELECT COUNT(*) AS remaining_tables FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?',[$table]);
$checks['own_fixture_removed']=(int)$remaining[0]['remaining_tables']===0;
$db=null;
ft111Report('mysql111-fulltext-relevance', ['Место', 'ID статьи', 'Заголовок'], $rows, $checks, $runtime, $details);
echo 'mysql111-fulltext-relevance: saved result.html and result.json
';
Ожидаемый результат
mysql111-fulltext-relevance: saved result.html and result.json

Запустите один архив и откройте result.html: он содержит конкретную таблицу выше. Подключение, создание данных и ft111-helper.php уже включены; запуск предыдущих примеров не требуется.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Добавьте вторую статью с теми же словами и тем же числом повторений, но другим id. Для равных score меньший id должен оказаться раньше. Если это не тот бизнес-приоритет, добавьте явно согласованный второй ключ, сохранив уникальный id последним.

Практический вопрос: Как связаны оценка MATCH, WHERE и ORDER BY. Разработчик не понимал, откуда берётся числовая оценка и почему фильтрация не задаёт порядок. Наши новые данные отделяют вычисление score, отбор и сортировку; советы из ответов не служат технической нормой. Ситуация взята из обсуждения; правила сверены с первичными источниками, код и учебные данные написаны отдельно.

Потребовать слово, исключить возврат и найти начало слова

Сотрудник ищет материал о посылке, но не о возвратах. Позже нужен поиск по началу слова: parcel и parcels. Это разные требования; покажем четыре строки поиска и четыре конкретных результата.

Исходные данные

IDbody
1parcel tracking
2parcel returns
3parcels delivery
4warehouse inventory
5invoice payment
Путь запроса и ответа
  1. parcel: отдельное слово → 1, 2
  2. +parcel -returns: слово нужно, возврат исключаем → 1
  3. +parce*: начало индексированного слова → 1, 2, 3
  4. -returns: только исключение не задаёт положительную выборку → пусто

Что получится

Строка AGAINST в BOOLEAN MODEID найденных статей
parcel1, 2
+parcel -returns1
+parce*1, 2, 3
-returnsНет строк

Начните с этой записи SQL

SELECT id,title FROM articles
WHERE MATCH(title,body) AGAINST (? IN BOOLEAN MODE)
ORDER BY id LIMIT 20;
-- В параметр передаём: +parcel -returns
-- Другой запрос: +parce*

Этот объясняющий фрагмент предполагает уже созданную таблицу articles и входные строки выше. В самостоятельной программе имя таблицы уникально, данные создаются автоматически, а поисковые значения передаются параметрами.

В BOOLEAN MODE знак + перед словом требует его наличия, - исключает совпадения с ним. Без знака слова необязательны относительно друг друга; строка из одних исключений ничего не выбирает. Звёздочка после фрагмента задаёт начало слова: parce* соответствует parcel и parcels. Это не шаблон LIKE; знак % здесь не подстановка, а звёздочка не означает произвольную середину текста.

Полная программа выполняет один подготовленный шаблон с четырьмя значениями и сверяет каждый набор ID. SQL-параметр отделяет текст поиска от команды SQL, но не отменяет язык BOOLEAN MODE. Если дать посетителю расширенный поиск, объясните +, - и кавычки; для обычного ввода без операторов лучше отдельный режим приложения. Не обещайте, что prepare превратит +parcel в буквальный плюс.

У InnoDB операторы + и - ставят перед словом; сочетания вроде ++parcel и хвостовой parcel+ некорректны. В нашем интерфейсном договоре строки заданы автором. Для произвольного расширенного ввода нужно ограничить длину/сложность и обработать ошибку синтаксиса поискового выражения как ошибку ввода, не показывая посетителю SQL или реквизиты базы.

Границы и ошибки

Префикс ищет начало существующего индексированного токена. Он не возвращает отсутствующие в индексе короткие слова волшебным образом и не выполняет русскую морфологию или исправление опечаток. Обычная подстрока, точный идентификатор и поиск слов должны иметь разные требования. Сравнение текста и LIKE поможет выбрать другой инструмент.

Полная самостоятельная программа и файлы
Потребовать слово, исключить возврат и найти начало словаСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/ft111-helper.php';
$db=ft111Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$table='mysql111_ft_'.bin2hex(random_bytes(8));
$created=false;
$details=[];
try {
    ft111Create($db,$table);
    $created=true;
ft111Seed($db,$table,[
        [1,7,'published','Отслеживание','parcel tracking'],
        [2,7,'published','Возврат','parcel returns'],
        [3,7,'published','Несколько посылок','parcels delivery'],
        [4,7,'published','Склад','warehouse inventory'],
        [5,7,'published','Платёж','invoice payment']
    ]);
    $queries=['parcel','+parcel -returns','+parce*','-returns'];
    $expected=[[1,2],[1],[1,2,3],[]];$rows=[];$actual=[];
    foreach($queries as $query){$ids=ft111Ids(ft111Find($db,$table,$query));$actual[]=$ids;$rows[]=[$query,ft111List($ids)];}
    $checks=['four_query_results'=>$actual===$expected,'prefix_is_not_exact_word'=>$actual[0]!==$actual[2],'negative_only_is_empty'=>$actual[3]===[]];
    $details=['queries'=>$queries,'actual_ids'=>$actual,'all_values_prepared'=>true];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    if($created){$db->exec('DROP TABLE '.ft111Name($table));}
}
$remaining=ft111Rows($db,'SELECT COUNT(*) AS remaining_tables FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?',[$table]);
$checks['own_fixture_removed']=(int)$remaining[0]['remaining_tables']===0;
$db=null;
ft111Report('mysql111-fulltext-boolean-terms', ['Строка AGAINST в BOOLEAN MODE', 'ID найденных статей'], $rows, $checks, $runtime, $details);
echo 'mysql111-fulltext-boolean-terms: saved result.html and result.json
';
Ожидаемый результат
mysql111-fulltext-boolean-terms: saved result.html and result.json

Запустите один архив и откройте result.html: он содержит конкретную таблицу выше. Подключение, создание данных и ft111-helper.php уже включены; запуск предыдущих примеров не требуется.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Для заданных пяти статей предскажите +parcel +returns: останется только ID 2. Для +parce* -returns останутся ID 1 и 3. Примените обе строки через тот же параметр и сравните с этим ответом.

Отличить точную последовательность слов от их наличия

Нужна инструкция именно с последовательностью parcel tracking. Статья, где между словами стоит urgent, и статья с обратным порядком не подходят. Проверяем требование фразой, а не сортируем похожие строки в PHP.

Исходные данные

IDbody
1parcel tracking
2parcel urgent tracking
3tracking parcel
4parcel
5warehouse inventory
Путь запроса и ответа
  1. Нужны оба слова → +parcel +tracking
  2. Нужна заданная последовательность → "parcel tracking"
  3. Первый запрос получает 1, 2, 3
  4. Второй получает только 1; лишние строки PHP не загружает

Что получится

ТребованиеID найденных статей
Оба слова: +parcel +tracking1, 2, 3
Фраза: "parcel tracking"1

Начните с этой записи SQL

SELECT id,title FROM articles
WHERE MATCH(title,body) AGAINST (? IN BOOLEAN MODE)
ORDER BY id LIMIT 20;
-- Значение параметра вместе с двойными кавычками:
-- "parcel tracking"

Этот объясняющий фрагмент предполагает уже созданную таблицу articles и входные строки выше. В самостоятельной программе имя таблицы уникально, данные создаются автоматически, а поисковые значения передаются параметрами.

Двойные кавычки внутри значения AGAINST объединяют слова во фразу. В PHP это строка '"parcel tracking"': внешние одинарные кавычки задают PHP-строку, внутренние двойные — часть поискового значения. execute([$query]) передаст их серверу; знаки ? в SQL по-прежнему без кавычек.

Это совпадение последовательности поисковых слов, а не побайтовое равенство всей body. Пунктуация, разбиение на слова, стоп-слова и правила сравнения остаются частью парсера. Если нужен буквально весь текст, выбирают = с подходящим collation; если буквальная подстрока — отдельную операцию. Наши исходные строки намеренно используют ясные пробелы и индексируемые слова, чтобы показать именно порядок.

Границы и ошибки

Кавычки не обучают индекс значению слов. Запрос с одними стоп-словами или короткими неиндексированными фрагментами требует другой политики. Не смешивайте кавычки поискового языка, SQL и PHP: значение передаётся параметром, а не собирается вставкой посетительского текста в SQL.

Полная самостоятельная программа и файлы
Отличить точную последовательность слов от их наличияСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/ft111-helper.php';
$db=ft111Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$table='mysql111_ft_'.bin2hex(random_bytes(8));
$created=false;
$details=[];
try {
    ft111Create($db,$table);
    $created=true;
ft111Seed($db,$table,[
        [1,7,'published','Краткая инструкция','parcel tracking'],
        [2,7,'published','Срочное отслеживание','parcel urgent tracking'],
        [3,7,'published','Другой порядок','tracking parcel'],
        [4,7,'published','Одно слово','parcel'],
        [5,7,'published','Приёмка','warehouse inventory']
    ]);
    $both=ft111Ids(ft111Find($db,$table,'+parcel +tracking'));
    $phrase=ft111Ids(ft111Find($db,$table,'"parcel tracking"'));
    $rows=[['Оба слова: +parcel +tracking',ft111List($both)],['Фраза: "parcel tracking"',ft111List($phrase)]];
    $checks=['both_words_any_order'=>$both===[1,2,3],'adjacent_ordered_phrase'=>$phrase===[1]];
    $details=['both_word_ids'=>$both,'phrase_ids'=>$phrase,'phrase_value'=>'"parcel tracking"'];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    if($created){$db->exec('DROP TABLE '.ft111Name($table));}
}
$remaining=ft111Rows($db,'SELECT COUNT(*) AS remaining_tables FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?',[$table]);
$checks['own_fixture_removed']=(int)$remaining[0]['remaining_tables']===0;
$db=null;
ft111Report('mysql111-fulltext-phrase', ['Требование', 'ID найденных статей'], $rows, $checks, $runtime, $details);
echo 'mysql111-fulltext-phrase: saved result.html and result.json
';
Ожидаемый результат
mysql111-fulltext-phrase: saved result.html and result.json

Запустите один архив и откройте result.html: он содержит конкретную таблицу выше. Подключение, создание данных и ft111-helper.php уже включены; запуск предыдущих примеров не требуется.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Поменяйте запрос на "tracking parcel": ожидается только ID 3. Вставьте строку с body="parcel fast tracking": запрос на оба слова должен её найти, а фразовый parcel tracking — нет.

Отфильтровать организацию и статус до границы страницы

База знаний общая для нескольких организаций. Посетителю организации 7 нужна страница из двух опубликованных статей. Первое совпадение принадлежит организации 9, третье — черновик. Фильтр после LIMIT теряет место на странице и уже передаёт лишние данные приложению.

Исходные данные

IDОрганизацияСтатусbody
19publishedparcel tracking
27publishedparcel tracking
37draftparcel tracking
47publishedparcel delivery
57publishedparcel returns
67publishedwarehouse inventory
Путь запроса и ответа
  1. Слишком ранний LIMIT 2: глобальные ID 1 и 2
  2. Фильтр в PHP оставляет только ID 2: страница неполная
  3. WHERE объединяет tenant=7, published и совпадение
  4. ORDER BY id LIMIT 2 → ID 2 и 4
  5. SELECT отдаёт id,title; чужой текст не входит в результат

Что получится

СпособID статьиЗаголовок
Фильтр PHP после LIMIT2Отследить отправление
Все условия в SQL2Отследить отправление
Все условия в SQL4Сроки доставки

Начните с этой записи SQL

SELECT id,title FROM articles
WHERE tenant_id = ?
  AND status = 'published'
  AND MATCH(title,body) AGAINST (? IN BOOLEAN MODE)
ORDER BY id
LIMIT 2;

Этот объясняющий фрагмент предполагает уже созданную таблицу articles и входные строки выше. В самостоятельной программе имя таблицы уникально, данные создаются автоматически, а поисковые значения передаются параметрами.

tenant_id — идентификатор организации. AND требует выполнения всех трёх условий: нужная организация, опубликованная статья, соответствие поиску. Только затем из логически подходящей выдачи выбирается страница. Это объяснение смысла SQL, не обещание физического порядка операций оптимизатора. В полном сравнении плохой вариант намеренно читает всего две глобальные строки, а хороший — две разрешённые; массовой загрузки базы нет.

Организацию для запроса приложение получает из проверенного контекста текущего пользователя. Простая подстановка tenant_id из GET не обеспечивает доступ. Условия нужно применять также к отдельному просмотру статьи, количеству результатов, выгрузкам и подсказкам. Параметризация предотвращает смешивание SQL с данными; она не заменяет проверку полномочий.

Здесь выбран бизнес-порядок id. Если нужен рейтинг, добавьте score и ORDER BY score DESC,id по предыдущему примеру, сохранив те же ограничения в WHERE. B-tree по tenant/status и FULLTEXT решают разные части задачи; наличие обоих не доказывает совместного использования или минимальной стоимости. План конкретного запроса и реальные объёмы проверяют отдельно.

Границы и ошибки

Результат из общей таблицы всё ещё ранжируется по общей поисковой коллекции, если используете score: фильтр организации сам по себе не создаёт отдельный полнотекстовый корпус. При строгом разделении поисковой статистики нужна отдельная архитектура. Глава показывает форму запроса и строки результата, а не готовую систему авторизации.

Полная самостоятельная программа и файлы
Отфильтровать организацию и статус до границы страницыСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/ft111-helper.php';
$db=ft111Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$table='mysql111_ft_'.bin2hex(random_bytes(8));
$created=false;
$details=[];
try {
    ft111Create($db,$table);
    $created=true;
ft111Seed($db,$table,[
        [1,9,'published','Другая организация','parcel tracking'],
        [2,7,'published','Отследить отправление','parcel tracking'],
        [3,7,'draft','Ещё не опубликовано','parcel tracking'],
        [4,7,'published','Сроки доставки','parcel delivery'],
        [5,7,'published','Возврат отправления','parcel returns'],
        [6,7,'published','Работа склада','warehouse inventory']
    ]);
    $global=ft111Rows($db,'SELECT id,tenant_id,status,title FROM '.ft111Name($table).' WHERE MATCH(title,body) AGAINST (? IN BOOLEAN MODE) ORDER BY id LIMIT 2',['parcel']);
    $late=array_values(array_filter($global,fn($row)=>(int)$row['tenant_id']===7 && $row['status']==='published'));
    $tenant=7;$query='parcel';
    $screen=ft111Rows($db,'SELECT id,title FROM '.ft111Name($table).' WHERE tenant_id=? AND status=? AND MATCH(title,body) AGAINST (? IN BOOLEAN MODE) ORDER BY id LIMIT 2',[$tenant,'published',$query]);
    $rows=[];
    foreach($late as $row){$rows[]=['Фильтр PHP после LIMIT',(int)$row['id'],$row['title']];}
    foreach($screen as $row){$rows[]=['Все условия в SQL',(int)$row['id'],$row['title']];}
    $checks=['late_filter_loses_second_card'=>ft111Ids($late)===[2],'sql_returns_two_allowed_cards'=>ft111Ids($screen)===[2,4],'only_required_output_columns'=>array_keys($screen[0])===['id','title']];
    $details=['global_ids'=>ft111Ids($global),'late_filtered_ids'=>ft111Ids($late),'screen_ids'=>ft111Ids($screen),'tenant_from_trusted_context'=>7];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    if($created){$db->exec('DROP TABLE '.ft111Name($table));}
}
$remaining=ft111Rows($db,'SELECT COUNT(*) AS remaining_tables FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?',[$table]);
$checks['own_fixture_removed']=(int)$remaining[0]['remaining_tables']===0;
$db=null;
ft111Report('mysql111-fulltext-screen-filter', ['Способ', 'ID статьи', 'Заголовок'], $rows, $checks, $runtime, $details);
echo 'mysql111-fulltext-screen-filter: saved result.html and result.json
';
Ожидаемый результат
mysql111-fulltext-screen-filter: saved result.html and result.json

Запустите один архив и откройте result.html: он содержит конкретную таблицу выше. Подключение, создание данных и ft111-helper.php уже включены; запуск предыдущих примеров не требуется.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Переключите разрешённую организацию на 9: ожидается только ID 1. Для организации 7 опубликуйте в учебных входах ID 3: первые два результата станут 2 и 3. Объясните, почему сортировка массива уже после LIMIT 2 этого не восстановит.

Разобрать пустой поиск: короткое слово, стоп-слово, COMMIT

Текст в базе есть, но поиск пустой. Разделим три причины на проверяемые случаи: qx короче допустимого слова, about исключён стандартным списком, а только что вставленная строка ещё не подтверждена транзакцией.

Исходные данные

IDbody
1qx about parcel warehouse
2invoice accounts
3tracking return
4stock manual
5, вставляется позжеquartzstone observer
Путь запроса и ответа
  1. Читаем фактические настройки: минимум 3, стандартные стоп-слова
  2. qx и about присутствуют в body, но не дают FULLTEXT-совпадений
  3. LOCATE подтверждает буквальное присутствие qx
  4. INSERT строки 5: обычный SELECT видит свою запись
  5. COMMIT → quartzstone становится видимым полнотекстовому поиску

Что получится

ПроверкаID найденных статей
FULLTEXT: qxНет строк
FULLTEXT: aboutНет строк
LOCATE: qx в body1
Обычный SELECT до COMMIT5
FULLTEXT: quartzstone до COMMITНет строк
FULLTEXT: quartzstone после COMMIT5

Начните с этой записи SQL

SELECT id FROM articles
WHERE MATCH(title,body) AGAINST (? IN BOOLEAN MODE);
-- qx: слишком короткий токен при minimum=3
-- about: входит в стандартный список стоп-слов

SELECT id FROM articles WHERE LOCATE(?,body)>0;
-- Буквальная подстрока — другая задача, а не FULLTEXT

-- INSERT внутри транзакции сначала подтверждаем COMMIT,
-- затем ожидаем его появления в FULLTEXT-поиске.

Этот объясняющий фрагмент предполагает уже созданную таблицу articles и входные строки выше. В самостоятельной программе имя таблицы уникально, данные создаются автоматически, а поисковые значения передаются параметрами.

Токен — единица, которую парсер выделил из текста. У стандартного InnoDB-парсера учитываются ограничения длины; в контрольном окружении минимум равен 3. qx имеет два символа. about длиннее, но присутствует в стандартном списке стоп-слов. Программа сначала читает innodb_ft_min_token_size и настройки списка, а при другом договоре останавливается с объяснением: чужое окружение нельзя объявлять совпавшим с ожидаемой таблицей.

LOCATE(?,body)>0 проверяет подстроку без требования FULLTEXT-индекса и находит ID 1. Это полезная диагностика присутствия данных, не универсальная замена поискового индекса для большой базы: она может потребовать просмотра множества строк. Для короткого артикула обычно лучше отдельная колонка и точное равенство; при поиске документа решите, что должен означать слишком короткий ввод.

Во второй части BEGIN и INSERT создают неподтверждённую строку 5. Обычный SELECT этого соединения её видит, а FULLTEXT ещё нет. После COMMIT поиск quartzstone возвращает ID 5. Именно фиксация, а не sleep или повторные запросы с задержкой, меняет этот результат. Помните о границах транзакции.

Изменение минимальной длины и списка стоп-слов относится к настройке сервера/индекса. Для некоторых параметров нужен перезапуск и перестроение FULLTEXT; простой повтор SELECT не переиндексирует старые данные. Этот архив только читает настройки и не меняет сервер. Правило «слово встречается в половине строк» из старых примеров MyISAM не следует переносить на InnoDB.

Границы и ошибки

Используется обычный парсер, не ngram и не MeCab; их границы отличаются. qx выбран вместо it, чтобы короткий токен не смешивался со стоп-словом. Убийство процесса может обойти finally и оставить его новую учебную таблицу; README объясняет точечную очистку. Ошибка подключения, неверный индекс и отсутствие совпадений — разные состояния, не один универсальный пустой массив.

Полная самостоятельная программа и файлы
Разобрать пустой поиск: короткое слово, стоп-слово, COMMITСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/ft111-helper.php';
$db=ft111Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$table='mysql111_ft_'.bin2hex(random_bytes(8));
$created=false;
$details=[];
try {
    ft111Create($db,$table);
    $created=true;
$settings=ft111Rows($db,'SELECT @@innodb_ft_min_token_size AS minimum,@@innodb_ft_enable_stopword AS stopwords,@@innodb_ft_server_stopword_table AS server_stopwords,@@innodb_ft_user_stopword_table AS user_stopwords')[0];
    if((int)$settings['minimum']!==3 || (int)$settings['stopwords']!==1 || !in_array($settings['server_stopwords'],[null,''],true) || !in_array($settings['user_stopwords'],[null,''],true)){
        throw new RuntimeException('This diagnostic expects InnoDB minimum=3 and the enabled default stopword list; no settings were changed');
    }
    ft111Seed($db,$table,[
        [1,7,'published','Разбор текста','qx about parcel warehouse'],
        [2,7,'published','Настройка','invoice accounts'],
        [3,7,'published','Статус','tracking return'],
        [4,7,'published','Остаток','stock manual']
    ]);
    $short=ft111Ids(ft111Find($db,$table,'qx'));
    $stop=ft111Ids(ft111Find($db,$table,'about'));
    $literal=ft111Rows($db,'SELECT id FROM '.ft111Name($table).' WHERE LOCATE(?,body)>0 ORDER BY id LIMIT 20',['qx']);
    $db->beginTransaction();
    $insert=$db->prepare('INSERT INTO '.ft111Name($table).' (id,tenant_id,status,title,body) VALUES (?,?,?,?,?)');
    $insert->execute([5,7,'published','Новое наблюдение','quartzstone observer']);
    $ordinary=ft111Rows($db,'SELECT id FROM '.ft111Name($table).' WHERE id=?',[5]);
    $before=ft111Ids(ft111Find($db,$table,'quartzstone'));
    $db->commit();
    $after=ft111Ids(ft111Find($db,$table,'quartzstone'));
    $rows=[['FULLTEXT: qx',ft111List($short)],['FULLTEXT: about',ft111List($stop)],['LOCATE: qx в body',ft111List(ft111Ids($literal))],['Обычный SELECT до COMMIT',ft111List(ft111Ids($ordinary))],['FULLTEXT: quartzstone до COMMIT',ft111List($before)],['FULLTEXT: quartzstone после COMMIT',ft111List($after)]];
    $checks=['short_token_absent'=>$short===[],'default_stopword_absent'=>$stop===[],'literal_substring_exists'=>ft111Ids($literal)===[1],'ordinary_read_sees_own_insert'=>ft111Ids($ordinary)===[5],'fulltext_requires_commit'=>$before===[] && $after===[5]];
    $details=['settings'=>$settings,'actual_ids'=>['short'=>$short,'stop'=>$stop,'substring'=>ft111Ids($literal),'before_commit'=>$before,'after_commit'=>$after],'global_settings_changed'=>false];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    if($created){$db->exec('DROP TABLE '.ft111Name($table));}
}
$remaining=ft111Rows($db,'SELECT COUNT(*) AS remaining_tables FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?',[$table]);
$checks['own_fixture_removed']=(int)$remaining[0]['remaining_tables']===0;
$db=null;
ft111Report('mysql111-fulltext-short-tokens', ['Проверка', 'ID найденных статей'], $rows, $checks, $runtime, $details);
echo 'mysql111-fulltext-short-tokens: saved result.html and result.json
';
Ожидаемый результат
mysql111-fulltext-short-tokens: saved result.html and result.json

Запустите один архив и откройте result.html: он содержит конкретную таблицу выше. Подключение, создание данных и ft111-helper.php уже включены; запуск предыдущих примеров не требуется.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Замените qx на qxz в исходном тексте и запросе: при минимуме 3 и стандартном списке слово должно найти ID 1. Затем выполните ROLLBACK вместо COMMIT для новой строки 5: она не появится ни в обычном чтении после отката, ни в FULLTEXT.

Практический вопрос: Индекс есть, но короткий поисковый текст ничего не находит. В вопросе после создания FULLTEXT запрос по abc возвращал пустой ответ. Там использовался MyISAM; наша современная глава явно отделяет его настройки от InnoDB и проверяет qx, стоп-слово about и COMMIT как разные причины. Ситуация взята из обсуждения; правила сверены с первичными источниками, код и учебные данные написаны отдельно.

Собрать поисковый запрос под конкретный экран

Сначала определите смысл совпадения: слово, фраза, префикс или буквальная строка. Затем задайте доступные организации/статусы, нужные столбцы и порядок страницы. Если результат пуст, проверьте данные, индекс, токены и фиксацию транзакции. Связанные таблицы и форму выдачи продолжает глава об экранах с несколькими таблицами, выбор параметров — подготовленные фильтры, а чтение результата — результаты PDO.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.