[СДЕЛАЙ САМ] Поиск на сайте по-быстрому

ВАЖНО!!!

Данное решение не является учебным и рекомендуется к использованию только на собственных проектах, с целью освоения работы с api modx. Для изучения лучших практик, анализируйте код дополнений с сайта https://modstore.pro/

Я знаю, что есть два отличных компонента для организации поиска по сайту это SimpleSearch, который по-моему не работает с чанками на fenom, и mSearch2, который платный и большой. В связи с этим я решил написать свой вариант поиска — максимально простой и понятный, да неуниверсальный, но позволяющий по нескольким ключевым словам найти нужный ресурс. По сути это форма. которая через ajax отправляется на сервер, где формируется sql-запрос в БД и результат возвращается на фронт. Итак перейдём к коду.

ФРОНТ
Тут использована сетка bootstrap5, иконочный шрифт FontAwesome
Если решите менять вёрстку обязательно нужно сохранить все тэги input, все атрибуты id, все классы начинающиеся с js.
<div class="container">
        <form class="row mt-lg-5 jsSearchForm">
            <input type="hidden" name="action" value="search">
            <input type="hidden" name="tpl" value="@FILE chunks/blocks/service-item.html">
            <div class="col-9">
                <label class="label-text par">
                    <input type="text" name="query" class="input" placeholder="Например, купить в 1 клик">
                </label>
            </div>
            <div class="col-3">
                <button class="btn btn-orange btn-cart" type="submit">
                    <i class="fa fa-search"></i>
                </button>
            </div>
            <div class="col-12">
                <span class="text-red par jsError"><!-- сюда будем вставлять текст ошибки --></span>
            </div>
        </form>
    </div>
<div class="container mt-lg-4 mt-3" id="productList">
<!-- сюда будем вставлять результаты поиска -->
</div>

JAVASCRIPT
Я использовал чистый JavaScript для отправки ajax.
// вешаем обработчик на форму поиска 
 document.querySelector('.jsSearchForm').addEventListener('submit', function (e) {
     e.preventDefault();
     let formData = new FormData(e.target.closest('form'));
     sendAjax(formData);
});

// отправляем ajax
function sendAjax(params) {
    // Создаем экземпляр класса XMLHttpRequest
    const request = new XMLHttpRequest();
        
     // Указываем путь до файла на сервере, который будет обрабатывать наш запрос
    const url = document.location.href;

     // Указываем что соединение у нас будет POST
    request.open('POST', url);

    request.responseType = 'json'; // ответ должен быть в формате json
    
    // обрабатываем ответ сервера
    request.addEventListener("readystatechange", () => {
            if (request.readyState === 4 && request.status === 200) {
                //console.log(request.response);
                // показываем результаты поиска
                if(document.getElementById('productList') && request.response.products){
                    document.getElementById('productList').innerHTML = request.response.products;
                } 
                // выводим ошибки если есть
                if(document.querySelector('.jsError') && !request.response.success){
                    document.querySelector('.jsError').innerHTML = request.response.message;
                }
                
                // убираем ранее выведенные ошибки
                if(document.querySelector('.jsError') && request.response.success){
                    document.querySelector('.jsError').innerHTML = '';
                }
            }
        });
       // Вот здесь мы и передаем строку с данными, которую формировали выше. И собственно выполняем запрос.
       request.send(params);
    }

PHP
На сервере я использовал два сниппета ajaxReceiver для принятия запроса и searchResources для поиска.

Сниппет ajaxReceiver
<?php
// Откликаться будет ТОЛЬКО на ajax запросы методом POST
if($_SERVER['REQUEST_METHOD'] != 'POST' ) {return;}

// Сниппет будет обрабатывать не один вид запросов, поэтому работать будем по запрашиваемому действию
// Если в массиве POST нет действия - выход
if (empty($_POST['action'])) {return;}

// А если есть - работаем
$res = '';
$action = $_POST['action'];
switch ($action) {
    case 'search':
	    $res = $modx->runSnippet('searchResources');
    break;
	
	// А вот сюда потом добавлять новые методы prodFastView
}

// Если у нас есть, что отдать на запрос - отдаем и прерываем работу парсера MODX
if (!empty($res)) {
	die($res);
}

Сниппет searchResources
<?php
// задаем переменные
$res = [];
$query = strip_tags($_POST['query']);
$tpl = $_POST['tpl'];
if($_POST['minQuery'] && is_numeric($_POST['minQuery'])){
    $minQuery = (int)$_POST['minQuery'];
}
else{
    $minQuery = 3;
}
/*
Поля для поиска совпадений можно передать с фронта, но стоит помнить, 
что запрос делается к модели класса msProduct, 
а значит и поля можно использовать только из таблицы ms2_products и site_content.
Если нужны другие поля, то нужно либо менять класс либо использовать JOIN.
*/
if(isset($_POST['fields'])){
    $fields =  explode(',',$_POST['fields']); // строка вида  -  'pagetitle,longtitle,menutitle'
}
else{
    $fields = ['pagetitle','longtitle','menutitle']; // либо массив
}

// проверяем возможность дальнейшей работы
if(!$query){return false;}
if(mb_strlen(str_replace(' ', '', $query)) < $minQuery){
    $res = ['success' => false, 'message' => 'Количество символов в запросе должно быть больше ' . $minQuery];
    return json_encode($res);
}

// формируем запрос в БД
$where = [];
$dbQuery = $modx->newQuery('msProduct');
foreach($fields as $k => $field){
    if($k == 0){
        $where[$field.':LIKE'] = '%'.str_replace(' ', '%',$query).'%'; 
    }
    else{
        $where['OR:'.$field.':LIKE'] = '%'.str_replace(' ', '%',$query).'%';
    }
}
$dbQuery->where( $where );
$dbQuery->select(['id']);
// выполняем запрос в БД
if ($dbQuery->prepare() && $dbQuery->stmt->execute()) {
    $ids = $dbQuery->stmt->fetchAll(PDO::FETCH_COLUMN);
}
/*
Дальше возможны варианты, мне требовался поиск по товарам, 
поэтому я просто сформировал массив id-шников и вызвал сниппет msProducts,
а в целом можно писать любую логику.
*/
// если что-то найдено
if(count($ids)){
    $pdoTools = $modx->getService('pdoTools');
    $res['products'] = $pdoTools->runSnippet('msProducts', array(
                    'parents' => 9,
                    'resources'=> implode(',', $ids),
                    'limit' => 0,
                    'sortby' => ['menuindex' => 'ASC'],
                    'tpl' => $tpl
            ));
    $res['success'] = true;
}
// если ничего нет
else{
    $res = ['success' => false, 'message' => 'По запросу <b class="text-orange">«'.$query.'»</b> ничего не найдено. Попробуйте убрать окончание слова.'];
}

return json_encode($res);

АЛЬТЕРНАТИВНЫЙ ВАРИАНТ
Пожалуй, этот способ ещё проще, но требует чтобы были установлены AjaxForm и FormIt.

ФРОНТ
Сначала вызываем AjaxForm
<div class="container">
        {'!AjaxForm' | snippet: [
            'snippet' => 'searchResources',
            'tpl' => '@FILE chunks/blocks/service-item.html',
            'form' => '@FILE chunks/forms/searchForm.html',
            'fieldsForSearch' => 'pagetitle,longtitle,menutitle',
            'validate' => 'query:required:minLength=^3^',
            'placeholderPrefix' =>'find.'
        ]}
    </div>
 <div class="container mt-lg-4 mt-3" id="productList">
<!-- сюда будем вставлять результаты поиска -->
  </div>
Чанк с формой chunks/forms/searchForm.html выглядит так
<form class="row mt-lg-5">
    <div class="col-9">
        <label class="label-text par">
            <input type="text" name="query" class="input" placeholder="Например, купить в 1 клик">
        </label>
    </div>
    <div class="col-3">
        <button class="btn btn-orange btn-cart" type="submit">
            <i class="fa fa-search"></i>
        </button>
    </div>
    <div class="col-12 error_query par"><!-- сюда будем вставлять текст ошибки --></div>
</form>

JAVASCRIPT
Отправку запроса делает AjaxForm, на остается только принять ответ и вывести данные.
$(document).on('af_complete', function(event, response){
        if (!response.success) {
            if (typeof response.data.error !== "string") {
                for (let key in response.data.error) {
                    let keys = key.split('.');
                    if ($(response.form).find('.error_' + keys[2]).length) {
                        $(response.form).find('.error_' + keys[2]).html(response.data.error[key]); // выводим текст ошибки
                    }
                }
            }
        }
        else{
            if($('#productList')){
                $('#productList').html(response.data.products); // выводим результаты поиска
            }
        }
    });

PHP
В этом случае нам нужен только сниппет searchResources с небольшими изменениями
<?php
//проверяем форму
$result = $modx->runSnippet('FormIt', $scriptProperties);
foreach($modx->placeholders as $key => $ph){
    if(strpos($key, 'find.error.') === 0){
        $placeholders[$key] = $ph;
    } 
}
//если нет ошибок работаем дальше
if(!count($placeholders)){
    // задаем переменные
    $res = [];
    $query = strip_tags($_POST['query']);
    $tpl = $scriptProperties['tpl'];
    $fields = explode(',',$scriptProperties['fieldsForSearch'])  ?: ['pagetitle','longtitle','menutitle'];
    // проверяем, чтобы поля для поиска были массивом
    if(is_array($fields)){
        // формируем запрос в БД
        $where = [];
        $dbQuery = $modx->newQuery('msProduct');
        foreach($fields as $k => $field){
            if($k == 0){
                $where[$field.':LIKE'] = '%'.str_replace(' ', '%',$query).'%'; 
            }
            else{
                $where['OR:'.$field.':LIKE'] = '%'.str_replace(' ', '%',$query).'%';
            }
        }
        $dbQuery->where( $where );
        $dbQuery->select(['id']);
        // выполняем запрос в БД
        if ($dbQuery->prepare() && $dbQuery->stmt->execute()) {
            $ids = $dbQuery->stmt->fetchAll(PDO::FETCH_COLUMN);
        }
        
        // если что-то найдено
        if(count($ids)){
            $pdoTools = $modx->getService('pdoTools');
            $products = $pdoTools->runSnippet('msProducts', array(
                            'parents' => 9,
                            'resources'=> implode(',', $ids),
                            'limit' => 0,
                            'sortby' => ['menuindex' => 'ASC'],
                            'tpl' => $tpl
                    ));
            $res['success'] = true;
            return $AjaxForm->success('Результатов '.count($ids).' шт.', array('products' => $products));
        }
        // если ничего нет
        else{
            return $AjaxForm->success('По запросу «'.$query.'» ничего не найдено. Попробуйте убрать окончание слова.');
        } 
    }else{
        return $AjaxForm->error('Неверный формат параметра $fields'); 
    }
}
//если есть ошибки
else{
    return $AjaxForm->error('В форме содержатся ошибки', array('error' => $placeholders)); 
}
Артур Шевченко
22 января 2021, 21:08
modx.pro
5
2 428
+10
Поблагодарить автора Отправить деньги

Комментарии: 27

Артем
22 января 2021, 22:28
+2
<input type="hidden" name="tpl" value="@FILE chunks/blocks/service-item.html">
Вот это очень плохо, вот прям очень. Не нужно прокидывать tpl с фронта, его нужно задавать строго на сервере.

$resources = $modx->getIterator('msProduct',$dbQuery);
Объекты тут вообще не нужны, гораздо правильнее заменить это на
$dbQuery->select(['id']);
if ($dbQuery->prepare() && $dbQuery->stmt->execute()) {
    $ids = $dbQuery->stmt->fetchAll(PDO::FETCH_COLUMN);
}

$query было бы неплохо хотя бы поверхностно обработать, т.к. он дальше напрямую передается в запрос.

$minQuery = $_POST['minQuery'] ?: 3;
Можно добавить (int).
    Артур Шевченко
    22 января 2021, 22:34
    0
    Вот это очень плохо, вот прям очень. Не нужно прокидывать tpl с фронта, его нужно задавать строго на сервере.
    Почему?

    Объекты тут вообще не нужны
    С одной стороны да, а с другой, изначально я не планировал использовать msProducts, поэтому и получал объекты.

    неплохо хотя бы поверхностно обработать
    Прям согласен.
    $query = strip_tags($_POST['query']); // хватит?
    Можно добавить (int).
    Тогда уже так
    if($_POST['minQuery'] && is_numeric($_POST['minQuery'])){
        $minQuery = (int)$_POST['minQuery'];
    }
    else{
        $minQuery = 3;
    }
      Артем
      22 января 2021, 23:27
      +2
      Почему?
      Потому что value="@CODE {$modx->removeCollection('modResource')}"

      С одной стороны да, а с другой, изначально я не планировал использовать msProducts, поэтому и получал объекты.
      Если у тебя будет 30к товаров, то getIterator сожрет всю оперативку даже не дойдя до половины.

      $query = strip_tags($_POST['query']); // хватит?
      Конечно, защищает от любых sql-инъекций.

      Тогда уже так
      достаточно просто
      $minQuery = (int) $_REQUEST['minQuery'] ?: 3;
        Артур Шевченко
        23 января 2021, 00:39
        0
        Потому что value="@CODE {$modx->removeCollection('modResource')}"
        Понял. а если сделать проверку типа
        if(strpos($_POST['tpl'], '@FILE') !== false){
        $tpl = $_POST['tpl'];
        }
        достаточно просто
        А если $_REQUEST['minQuery'] будет равна 'три', тут
        if(mb_strlen(str_replace(' ', '', $query)) < $minQuery){
            $res = ['success' => false, 'message' => 'Количество символов в запросе должно быть больше ' . $minQuery];
            return json_encode($res);
        }
        будет ошибка, нет?
          Артем
          23 января 2021, 01:30
          +3
          Понял. а если сделать проверку типа
          Тогда остается доступ к произвольным *.tpl, *.html и *.php файлам. В общем, это довольно скользкая штука и рано или поздно может аукнуться. Хорошей практикой будет просто указывать этот шаблон на сервере и не париться о всяких проверках.
          Тем более, в твоем примере просто проверяется наличие '@FILE' в строке, а не на первой позиции. Это я к тому, что накосячить с этим гораздо проще, чем кажется.

          А если $_REQUEST['minQuery'] будет равна 'три'
          То она просто скастуется до 0 через (int), а затем будет взято 3. В общем, в этой переменной всегда будет число и ошибки не будет. Максимум — условие не пройдет, если передать отрицательный minQuery.
Aleksandr Huz
22 января 2021, 22:33
0
document.querySelector('.jsSearchForm').addEventListener(event, function (e) {
     e.preventDefault();
     let formData = new FormData(e.target.closest('form'));
     sendAjax(formData);
});
Точно работает?
event — это submit?
e.target — если сабмит, то это и есть форма.
    Артур Шевченко
    22 января 2021, 22:37
    0
    Сорри, я правил, у меня в оригинале другой код))) Спасибо что заметили. Исправил, там конечно же должно быть submit.
    Павел Бигель
    23 января 2021, 19:13
    +1
    Люди продолжают изобретать велосипеды
    тыц
      Артур Шевченко
      23 января 2021, 19:23
      0
      Во-первых, мне лично не очень понятно как и что я могу передать в этот процессор и что он мне вернёт, где можно почитать как с ним работать?
      Во-вторых, зачем мне поиск по TV, чанкам, сниппетам и пользователям, это избыточный функционал для моей задачи.
      В-третьих, я не учел что такой процессор существует.
      В-четвёртых, для саморазвития полезно изобрести велосипед другой, в конце концов я никого на нём ездить не заставляю:-)
        Павел Бигель
        23 января 2021, 19:25
        0
        Никто не мешает занаследовать процессор и искать по чему хочется.
          Артур Шевченко
          23 января 2021, 19:37
          0
          Таки согласен, см. в-третьих. Постараюсь написать и такой вариант. Хотя я тот ещё любитель ООП))
            Роман
            25 января 2021, 11:11
            0
            Как можно не учесть, если внутри админки есть поиск. Ну да ладно. На сколько я понял, ваш поиск работает только по товарам?
              Артур Шевченко
              25 января 2021, 11:22
              0
              Ну да, по товарам. Если поменять класс будет работать по любым ресурсам.
            Артем
            23 января 2021, 19:48
            +1
            Никто не мешает занаследовать процессор и искать по чему хочется.
            Дык нафиг он нужен, если там нет ни одного метода, который не нужно переопределять?
            searchResources юзает getCollection, ровно как и остальные методы для поиска, тут можно передать привет оперативке.
            process тоже переопределять нужно.
            И что там в итоге остается, проверка прав?
              Артур Шевченко
              23 января 2021, 19:58
              0
              Господа, не ссортесь, право слово программирование штука гибкая и у задачи может быть много решений, в какой-то ситуации лучше одно решение, в какой-то другое. getCollection кушает много памяти это факт, но если ресурсов немного, то данное обстоятельство некритично. Хотя я склоняюсь на сторону Артёма использование процессора сложновато и плохо вписывается в понятие «по-быстрому», я сам новичок и писал для новичков, Артём подсказал, где поправить, чтобы было быстрее и безопаснее получился, на мой взгляд вполне простой и понятный код, неуниверсальный конечно, но такая задача и не ставилась.
          Sergey (Sentinel)
          24 января 2021, 23:01
          0
          @Павел Бигель Паш, занаследуй процессор и напиши поиск, и покажи как надо… а то тыц не понятна… :)))
            Артур Шевченко
            24 января 2021, 23:52
            0
            Думаю это лишнее, в конце концов, тут не курсы по программированию, а форум. Но если @Павел Бигель расскажет подробнее в чём плюсы его варианта, то это будет здорово. Пока я вижу только один — не нужно ничего изобретать, можно использовать как есть.
          Pavel Zarubin
          26 января 2021, 13:37
          +1
          Ужас, вся статья — пример того, как делать не нужно))

          На сервере я использовал два сниппета ajaxReceiver для принятия запроса и searchResources для поиска.
          Зачем? Во первых вызывая сниппет — ты проходишь полный цикл инициализации MODX, тебе нужно отдать очень простые результаты зачем тебе полностью инициализировать modx? Дикая нагрузка на сервер, очень медленная скорость. Также ты вызываешь еще один сниппет из этого сниппета, это прям ну вообще плохо
          $pdoTools->runSnippet('msProducts', array(
                                      'parents' => 9,
                                      'resources'=> implode(',', $ids),
                                      'limit' => 0,
                                      'sortby' => ['menuindex' => 'ASC'],
                                      'tpl' => $tpl
                              ));
          Эм… Зачем вызывать сниппет msProducts? Почему бы не использовать pdoFetch например и не задействовать парсер modx?

          И это лишь малая часть, тут вообще ни строчки правильного кода по сути нет…

          Короче без обид, Артур, учиться программировать не плохо, что мы видим из твоих статей, но пожалуйста, называй свои статьи не «Как сделать что то» а «Подскажите что я делаю не так» ну или хотя бы «Мой способ реализации бла-бла». Твой абсолютный говнокод же будут потом брать новички (что мы видим из количества добавленных в избранное), а учитывая как мало материалов по modx он еще и в гугл может попасть…
            Артур Шевченко
            26 января 2021, 14:01
            0
            Вообще без обид, меня никто не учил как правильно, делаю как могу, против конструктивной критики ничего не имею, поэтому спасибо. И несколько вопросов.
            Во первых вызывая сниппет — ты проходишь полный цикл инициализации MODX, тебе нужно отдать очень простые результаты зачем тебе полностью инициализировать modx?
            Что значит «полный цикл инициализации MODX» и что в этом страшного, если на странице зачастую вызывается несколько сниппетов?

            Зачем вызывать сниппет msProducts? Почему бы не использовать pdoFetch например и не задействовать парсер modx?
            Я понимаю, что это, скажем так, неоптимальное решение, но другой вариант это
            $pdoTools->getChunk($tpl, $data);
            А как без использования парсера? Мне же html нужен, а не сырые данные, или JS'ом вставлять?

            А что до названия, ну «Сделай Сам» или «Мой способ реализации бла-бла» так по-моему без разницы, на код это не влияет)))
              Pavel Zarubin
              26 января 2021, 15:06
              +1
              Что значит «полный цикл инициализации MODX» и что в этом страшного, если на странице зачастую вызывается несколько сниппетов?
              Полный цикл — значит что просто для того чтобы отдать тебе ответ в ajax запросе, modx полностью инциализируется, проверяет все права, делает 100500 запросов в бд и отжирает дофигища оперативы (сколько там? 100мб минимум наверное?). Эти 100мб можно сократить до одного, если прервать инциализацию modx, ну или хотя бы сделать это процессором, к слову так работает mFilter, там прям в конце ответа die() висит в плагине, что тоже к слову не очень ок, но хотя бы что то

              А как без использования парсера? Мне же html нужен, а не сырые данные, или JS'ом вставлять?
              1) Ну во первых надо было свой сервис сделать у него хандлер и пр, если уж так не хочется использовать процессоры
              2) Изучи docs.modx.pro/komponentyi/pdotools/parser, тут не очень подробно, но эксперементируя — понятно

              А что до названия, ну «Сделай Сам» или «Мой способ реализации бла-бла» так по-моему без разницы, на код это не влияет)))
              На код не влияет, а на восприятие людьми, еще менее знающими чем ты — еще как. Начинающие не знают что хорошо, а что плохо и запросто будут использовать твой код как пример хорошей практики и возможно на нем даже учится, вон, видишь, даже 4 человека в избранное твою статью добавили, значит планируют использовать твой код хотя бы в теории
                Артур Шевченко
                26 января 2021, 15:21
                0
                Я так понял хорошей и единственно верной практикой, если я хочу свой велосипед, является написание компонента с классами, процессорами и коннекторами. так?
                100мб можно сократить до одного, если прервать инциализацию modx
                Где прервать? И как с помощью die()?
                На код не влияет, а на восприятие людьми, еще менее знающими чем ты — еще как.
                Согласен. Предупреждение написал.
                  Pavel Zarubin
                  26 января 2021, 16:02
                  +2
                  Где прервать? И как с помощью die()?
                  Просто не нужно вызывать сниппет и все, если уж хочется без процессоров и по быстрому, всегда можно сделать php файлик, подключить туда modx и перевести его в API-мод, как тут например:
                  github.com/pavel-one/modxCustomAuth/tree/master/assets/components/customAuth

                  Я так понял хорошей и единственно верной практикой, если я хочу свой велосипед, является написание компонента с классами, процессорами и коннекторами. так?
                  Хорошей и верной практикой — да, единственной — нет, репозиторий который выше кинул поизучай, вполне себе рабочая практика написания своих велосипедов без знания ООП и принципов работы modx, собственно именно при таких же знаниях я это и писал
          Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
          27