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

Дерево руководства
Глава 117 · После таблиц, SELECT и параметров PDO
MySQL: полнотекстовый поиск от первого слова до страницы статей
Настоящий FULLTEXT-индекс, MATCH/AGAINST, релевантность, обязательные слова и фразы, фильтры организации, стоп-слова и видимость после COMMIT.
Как поиск слов отличается от поиска подстроки
Посетитель базы знаний ищет материал словами: «отслеживание посылки». Задача — найти подходящие статьи и вернуть нужную страницу, а не забрать все тексты в PHP и перебирать их массивом. Здесь начнём с одного индексируемого слова parcel и постепенно добавим рейтинг, исключения, фразы, организацию и диагностику пустой выдачи.
- INSERT сохраняет title и body статьи
- FULLTEXT составляет поисковый указатель слов
- Параметр поиска поступает в MATCH ... AGAINST
- WHERE оставляет подходящие и разрешённые строки
- 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 |
- Сохранённые title и body каждой статьи
- FULLTEXT ft_text разбирает текст на поисковые слова
- parcel → совпадения статей 1 и 3
- SELECT отдаёт только id и title
- 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 |
- Параметр parcel передаётся в два места SQL
- MATCH вычисляет score для каждой подходящей статьи
- WHERE исключает нулевые совпадения
- ORDER BY score DESC, id ASC задаёт выдачу
- LIMIT 3 → в наших данных только две карточки
Что получится
| Место | ID статьи | Заголовок |
|---|---|---|
| 1 | 1 | Отслеживание |
| 2 | 2 | Возврат |
Начните с этой записи 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. Это разные требования; покажем четыре строки поиска и четыре конкретных результата.
Исходные данные
| ID | body |
|---|---|
| 1 | parcel tracking |
| 2 | parcel returns |
| 3 | parcels delivery |
| 4 | warehouse inventory |
| 5 | invoice payment |
- parcel: отдельное слово → 1, 2
- +parcel -returns: слово нужно, возврат исключаем → 1
- +parce*: начало индексированного слова → 1, 2, 3
- -returns: только исключение не задаёт положительную выборку → пусто
Что получится
| Строка AGAINST в BOOLEAN MODE | ID найденных статей |
|---|---|
| parcel | 1, 2 |
| +parcel -returns | 1 |
| +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.
Исходные данные
| ID | body |
|---|---|
| 1 | parcel tracking |
| 2 | parcel urgent tracking |
| 3 | tracking parcel |
| 4 | parcel |
| 5 | warehouse inventory |
- Нужны оба слова → +parcel +tracking
- Нужна заданная последовательность → "parcel tracking"
- Первый запрос получает 1, 2, 3
- Второй получает только 1; лишние строки PHP не загружает
Что получится
| Требование | ID найденных статей |
|---|---|
| Оба слова: +parcel +tracking | 1, 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 |
|---|---|---|---|
| 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 |
- Слишком ранний LIMIT 2: глобальные ID 1 и 2
- Фильтр в PHP оставляет только ID 2: страница неполная
- WHERE объединяет tenant=7, published и совпадение
- ORDER BY id LIMIT 2 → ID 2 и 4
- SELECT отдаёт id,title; чужой текст не входит в результат
Что получится
| Способ | ID статьи | Заголовок |
|---|---|---|
| Фильтр PHP после LIMIT | 2 | Отследить отправление |
| Все условия в SQL | 2 | Отследить отправление |
| Все условия в SQL | 4 | Сроки доставки |
Начните с этой записи 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 исключён стандартным списком, а только что вставленная строка ещё не подтверждена транзакцией.
Исходные данные
| ID | body |
|---|---|
| 1 | qx about parcel warehouse |
| 2 | invoice accounts |
| 3 | tracking return |
| 4 | stock manual |
| 5, вставляется позже | quartzstone observer |
- Читаем фактические настройки: минимум 3, стандартные стоп-слова
- qx и about присутствуют в body, но не дают FULLTEXT-совпадений
- LOCATE подтверждает буквальное присутствие qx
- INSERT строки 5: обычный SELECT видит свою запись
- COMMIT → quartzstone становится видимым полнотекстовому поиску
Что получится
| Проверка | ID найденных статей |
|---|---|
| FULLTEXT: qx | Нет строк |
| FULLTEXT: about | Нет строк |
| LOCATE: qx в body | 1 |
| Обычный SELECT до COMMIT | 5 |
| FULLTEXT: quartzstone до COMMIT | Нет строк |
| FULLTEXT: quartzstone после COMMIT | 5 |
Начните с этой записи 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 объясняет точечную очистку. Ошибка подключения, неверный индекс и отсутствие совпадений — разные состояния, не один универсальный пустой массив.
Полная самостоятельная программа и файлы
<?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. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


