Blog: Classier Twitter threads - Tag Fulltext

MySQL fulltext: prosil bych jeden čaj

Mějme tabulku jídel na kterou chceme napsat hledání.

CREATE TABLE `food` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) COLLATE utf8_czech_ci NOT NULL,
  `description` text COLLATE utf8_czech_ci NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_czech_ci;

INSERT INTO `food` (`name`, `description`) VALUES
('Semtex', 'energiťák'), ('Čaj', 'heřmánkovej');

Máme dvě možnost, buďto použijeme search engine (ElasticSearch, Sphinx, …) nebo se s tím budeme srát v MySQL. No a aby to bylo zajímavé, tak se s tím pojďme srát :)

InnoDB neumí FULLTEXT index

První problém, jak ho vyřešit? Triggery.

Takže si vytvoříme tabulku do které budeme duplikovat data (což je v podstatě to stejné co byste dělali s externí službou na hledání)

CREATE TABLE `food_fulltext` (
  `food_id` int(11) NOT NULL,
  `name` varchar(255) COLLATE utf8_czech_ci NOT NULL,
  `description` text COLLATE utf8_czech_ci NOT NULL,
  PRIMARY KEY (`food_id`),
  FULLTEXT KEY `name_description` (`name`,`description`),
  FULLTEXT KEY `name` (`name`),
  FULLTEXT KEY `description` (`description`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_czech_ci;

A napíšeme si triggery

DELIMITER ;;

CREATE PROCEDURE `food_fulltext_update` (IN `updated_id` int(11))
BEGIN
    DECLARE `name` TEXT ;
    DECLARE `description` TEXT ;

    SELECT food.`name`, food.`description` INTO `name`, `description`
    FROM food WHERE `id` = `updated_id`;

    INSERT INTO `food_fulltext` (`food_id`, `name`, `description`) VALUES (`updated_id`, `name`, `description`)
    ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `description` = VALUES(`description`);
END;;

CREATE TRIGGER `food_ai` AFTER INSERT ON `food` FOR EACH ROW
IF @disable_triggers IS NULL THEN
    CALL food_fulltext_update(NEW.`id`);
END IF;;

CREATE TRIGGER `food_au` AFTER UPDATE ON `food` FOR EACH ROW
IF @disable_triggers IS NULL THEN
    CALL food_fulltext_update(NEW.`id`);
END IF;;

CREATE TRIGGER `food_ad` AFTER DELETE ON `food` FOR EACH ROW
IF @disable_triggers IS NULL THEN
    DELETE FROM food_fulltext WHERE `food_id` = OLD.`id`;
END IF;;

DELIMITER ;

INSERT INTO `food_fulltext` (`food_id`, `name`, `description`)
SELECT food.`id`, food.`name`, food.`description` FROM food

Ta proměnná @disable_triggers je vychytávka, aby se daly triggery vypnout při hromadných operacích nad daty. Trigger může jednoduchý update spomalit klidne i exponenciálně (fakt se to hodí mít možnost vypnout).

Fajn, takže při zápisu do tabulky s jídly se nám data zprelikují pod fulltext a můžeme hned začít s hledáním.

Hledáme Semtex

Takhle nějak by mohla vypadat search query (inspirovaná článkem od Jakuba)

SELECT food_id FROM food_fulltext
WHERE MATCH(name, description) AGAINST (? IN BOOLEAN MODE)
ORDER BY 5 * MATCH(name) AGAINST (?) + MATCH(description) AGAINST (?) DESC
LIMIT 1000

a když ji pak proženeme přes Nette\Database\Context

function search($string)
{

    $sql = "...";

    return $this->db->query($sql, $string, $string, $string)->fetchAll();
}

s hledaným výrazem od uživatele

dump($fulltext->search("Semtex")); // [['food_id' => 1]]

Super, našli jsme Semtex!

Hledáme Čaj

Jenže když dáme hledat čaj tak máme problém (konkrétně dva)

dump($fulltext->search("Čaj")); // []

Ten první je, že mysql má výchozí minimální délku slova pro fulltext větší než 3, to se dá změnit celkem snadno

$ sudo nano /etc/mysql/my.cnf
[mysqld]
# Fine Tuning
ft_min_word_len = 3
$ sudo service mysql restart
REPAIR TABLE `food_fulltext` QUICK;

Ten druhý problém je, že dost agresivně zohledňuje diakritiku, takže na dotaz "Čaj" se nám sice vrátí výsledek, ale na dotaz "caj" se nevrátí nic.

Zbavujeme se diakritiky

Protože chceme mít proces automatický, abychom nemuseli řešit ukládání do dvou tabulek, tak máme triggery. A protože máme triggery, musíme dělat konverzi na úrovni databáze.

DELIMITER ;;

--
-- https://github.com/falcacibar/mysql-routines-collection/blob/28ef383092ffa5a0e4e7e377fa5d1a3badcc488c/tr.func.sql
-- @author Felipe Alcacibar <[email protected]>
--
CREATE FUNCTION `strtr`(`str` TEXT, `dict_from` VARCHAR(1024), `dict_to` VARCHAR(1024)) RETURNS text LANGUAGE SQL DETERMINISTIC NO SQL SQL SECURITY INVOKER COMMENT ''
BEGIN
    DECLARE len INTEGER;
    DECLARE i INTEGER;

    IF dict_to IS NOT NULL AND (CHAR_LENGTH(dict_from) != CHAR_LENGTH(dict_to)) THEN
        SET @error = CONCAT('Length of dicts does not match.');
        SIGNAL SQLSTATE '49999'
            SET MESSAGE_TEXT = @error;
    END IF;

    SET len = CHAR_LENGTH(dict_from);
    SET i = 1;

    WHILE len >= i  DO
        SET @f = SUBSTR(dict_from, i, 1);
        SET @t = IF(dict_to IS NULL, '', SUBSTR(dict_to, i, 1));

        SET str = REPLACE(str, @f, @t);
        SET i = i + 1;

    END WHILE;

    RETURN str;
END;;

CREATE FUNCTION `to_ascii`(`str` TEXT) RETURNS text LANGUAGE SQL DETERMINISTIC NO SQL SQL SECURITY INVOKER COMMENT ''
BEGIN
    RETURN strtr(LOWER(str), 'áäčďéěëíµňôóöŕřšťúůüýžÁÄČĎÉĚËÍĄŇÓÖÔŘŔŠŤÚŮÜÝŽ', 'aacdeeeilnooorrstuuuyzaacdeeelinooorrstuuuyz');
END;;

Upravíme proceduru která synchronizuje fulltext

DELIMITER ;;

DROP PROCEDURE `food_fulltext_update`;;
CREATE PROCEDURE `food_fulltext_update` (IN `updated_id` int(11))
BEGIN
    DECLARE `name` TEXT ;
    DECLARE `description` TEXT ;

    SELECT to_ascii(food.`name`), to_ascii(food.`description`) INTO `name`, `description`
    FROM food WHERE `id` = `updated_id`;

    INSERT INTO `food_fulltext` (`food_id`, `name`, `description`) VALUES (`updated_id`, `name`, `description`)
    ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `description` = VALUES(`description`);
END;; -- 0.001 s

A ještě upravíme zpracování vstupu do SQLka

use Nette\Utils\Strings

function search($string)
{
    $string = Strings::lower(Strings::normalize($string);
    $string = Strings::replace($string, '/[^\d\w]/u', ' ');

    $words = Strings::split(Strings::trim($string), '/\s+/u');
    $words = array_unique(array_filter($words, function ($word) {
        return Strings::length($word) > 1;
    }));
    $words = array_map(function ($word) {
        return Strings::toAscii($word) . '*';
    }, $words);

    $string = implode(' ', $words);

    $sql = "...";

    return $this->db->query($sql, $string, $string, $string)->fetchAll();
}

Našli jsme čaj!

Už jenom otestovat

dump([
    $fulltext->search("Čaj"),
    $fulltext->search("Caj"),
    $fulltext->search("čaj"),
    $fulltext->search("caj"),
]); //  [['food_id' => 2]], [['food_id' => 2]], [['food_id' => 2]], [['food_id' => 2]]

a máme to hotovo. Doufám že tohle je naposledy co jsem musel řešit fulltext v MySQL a vám to přeji taky ;)

Continue reading ...