Smart Search for MODX Revolution
Technical specification
Project title: Improvement of the snippet for search in MODX Revo
Purpose: To provide a correct search on the MODX resource, taking into account the following requirements:
- Search should support multiple words in the query.
- All query words must be in the field.
pagetitle. - For numerical queries, look for only exact matches surrounded by gaps.
Main objectives:
-
Processing the search phrase:
- Separate query into words using
explode(" ", $query). - For numerical values add verification via
is_numericand enable accurate search using regular expressionsREGEXP).
- Separate query into words using
-
Generation of search conditions:
- Create an array of conditions for each word using
LIKEfor strings andREGEXPfor numbers. - Conditions must be combined through
AND.
- Create an array of conditions for each word using
-
Building a SQL query:
- Use API MODX (or direct SQL query) to generate a query that meets all conditions.
- Enable the output of the final SQL request using
toSQL()or equivalent.
-
Implementation of logic in snippet:
- Verify and integrate the terms into existing search code.
- Provide processing of search results with correct display.
-
Additional tasks:
- Implement the possibility of changing the target field (
pagetitle) through the snippet parameters. - Check compatibility with existing functionality and templates
tpl).
- Implement the possibility of changing the target field (
Output expected:
- A snippet that performs a search under the specified conditions returns the correct results and outputs the final SQL query for debugging.
Search page template
{if $searchParam != ''}
{set $resources = $_modx->runSnippet('!mSearch2Strict', [
'parents' => 0,
'limit' => 0,
'showLog' => 1,
'showSearchLog' => 1,
'queryVar' => 'search',
'where' => '{"class_key":"msProduct"}',
'returnIds' => 1,
])}
{/if}
{if $resources && $searchParam}
{$_modx->runSnippet('!mFilter2', [
'parents' => 0,
'element' => 'msProducts',
'includeThumbs' => 'thumb',
'limit' => 9,
'onlyIndex' => false,
'maxLimit' => 9,
'sortby' => '',
'includeTVs' => 'main_options',
'processTVs' => 'main_options',
'filters' => 'parent:categories',
'resources' => $resources,
'values_delimeter' => ';',
'suggestionsMaxResults' => 0,
'tpls' => '@FILE chunks/items/item.product.tpl, @FILE chunks/items/item.product_row.tpl',
'tplOuter' => '@FILE chunks/parts/search/part.search.mFilter2_outer.tpl',
'tplFilter.outer.default' => '@FILE chunks/parts/search/filters/part.search.filter.outer_parent.tpl',
'tplFilter.row.default' => '@FILE chunks/parts/search/filters/part.search.filter.parent.tpl',
'tplPageWrapper' => '@FILE chunks/base/pagination/base.pagination.wrapper.tpl',
'tplPage' => '@FILE chunks/base/pagination/base.pagination.page.tpl',
'tplPageActive' => '@FILE chunks/base/pagination/base.pagination.page_active.tpl',
'tplPageFirst' => '@INLINE',
'tplPageLast' => '@INLINE',
'tplPagePrev' => '@FILE chunks/base/pagination/base.pagination.page_prev.tpl',
'tplPageNext' => '@FILE chunks/base/pagination/base.pagination.page_next.tpl',
'tplPageFirstEmpty' => '@INLINE',
'tplPageLastEmpty' => '@INLINE',
'tplPagePrevEmpty' => '@FILE chunks/base/pagination/base.pagination.page_prev_empty.tpl',
'tplPageNextEmpty' => '@FILE chunks/base/pagination/base.pagination.page_next_empty.tpl',
'tplPageSkip' => '@FILE chunks/base/pagination/base.pagination.page_skip.tpl',
])}
{elseif $searchParam}
snippetmSearch2Strict
<?php
/** @var modX $modx */
/** @var array $scriptProperties */
/** @var mSearch2 $mSearch2 */
if (!$modx->loadClass('msearch2', MODX_CORE_PATH . 'components/msearch2/model/msearch2/', false, true)) {return false;}
$mSearch2 = new mSearch2($modx, $scriptProperties);
$mSearch2->pdoTools->setConfig($scriptProperties);
$mSearch2->pdoTools->addTime('pdoTools loaded.');
if (empty($queryVar)) {$queryVar = 'query';}
if (empty($parentsVar)) {$parentsVar = 'parents';}
if (empty($minQuery)) {$minQuery = $modx->getOption('index_min_words_length', null, 3, true);}
if (empty($htagOpen)) {$htagOpen = '';}
if (empty($htagClose)) {$htagClose = '';}
if (empty($outputSeparator)) {$outputSeparator = "\n";}
if (empty($plPrefix)) {$plPrefix = 'mse2_';}
$returnIds = !empty($returnIds);
$fastMode = !empty($fastMode);
$class = 'modResource';
$found = array();
$output = null;
$query = !empty($_REQUEST[$queryVar])
? $mSearch2->getQuery(rawurldecode($_REQUEST[$queryVar]))
: '';
if (empty($resources)) {
if (empty($query) && isset($_REQUEST[$queryVar])) {
$output = $modx->lexicon('mse2_err_no_query');
}
elseif (empty($query) && !empty($forceSearch)) {
$output = $modx->lexicon('mse2_err_no_query_var');
}
elseif (!empty($query) && !preg_match('/^[0-9]{2,}$/', $query) && mb_strlen($query,'UTF-8') < $minQuery) {
$output = $modx->lexicon('mse2_err_min_query');
}
$modx->setPlaceholder($plPrefix.$queryVar, $query);
if (!empty($output)) {
return !$returnIds
? $output
: '';
}
//echo 1; exit;
elseif (!empty($query)) {
/*
$decodedQuery = str_replace(' ', '%', rawurldecode($_REQUEST[$queryVar]));
//$decodedQuery = rawurldecode($_REQUEST[$queryVar]);
$q = $modx->newQuery('modResource');
$q->leftJoin('msProductData', 'Data', 'modResource.id = Data.id');
$q->where([
[
'deleted' => false,
'published' => true,
],
[
'pagetitle:LIKE' => '%' . $decodedQuery . '%',
'OR:longtitle:LIKE' => '%' . $decodedQuery . '%',
'OR:Data.article:LIKE' => '%' . $decodedQuery . '%'
]
]);
*/
/*
$q->where([
[
'deleted' => false,
'published' => true,
'pagetitle:REGEXP' => '(\b' . preg_quote($decodedQuery, '/') . '\b)',
'OR:longtitle:REGEXP' => '(\b' . preg_quote($decodedQuery, '/') . '\b)',
'OR:Data.article:REGEXP' => '(\b' . preg_quote($decodedQuery, '/') . '\b)',
]
]);
Apply an accurate search if the query consists of several words
if (strpos($decodedQuery, ' ') !== false) {
$q->where([
'deleted' => false,
'published' => true,
'OR:pagetitle:LIKE' => '%' . $decodedQuery . '%',
'OR:longtitle:LIKE' => '%' . $decodedQuery . '%',
'OR:Data.article:LIKE' => '%' . $decodedQuery . '%'
]);
} else {
$q->where([
'deleted' => false,
'published' => true,
'OR:pagetitle:LIKE' => '%' . $decodedQuery . '%',
'OR:longtitle:LIKE' => '%' . $decodedQuery . '%',
'OR:Data.article:LIKE' => '%' . $decodedQuery . '%'
]);
}
######
$q->select('`modResource`.`id`');
if ($q->prepare() && $q->stmt->execute()) {
$exact = $q->stmt->fetchAll(PDO::FETCH_COLUMN);
}
*/
$searchTerms = explode(" ", $query);
//$searchTerms = ['2', 'contact'];
//print_r($searchTerms);
// Basic conditions
$conditions = [
'modResource.deleted' => false,
'modResource.published' => true,
];
Form the LIKE conditions for each word
foreach ($searchTerms as $term) {
if (is_numeric($term)) {
$conditions[] = ['AND:pagetitle:REGEXP' => '(^|\s)'.$term.'(\s|$)'];
}else{
$conditions[] = ['AND:pagetitle:LIKE' => '%' . $term . '%'];
}
}
// Create a request
$searchQuery = $modx->newQuery('modResource');
// Indicate a sample of data
$searchQuery->select(['id', 'pagetitle', 'content']);
// Adding conditions
$searchQuery->where($conditions);
// Output generated SQL
$ids = array();
$resources = $modx->getCollection('modResource', $searchQuery);
//$sql = $searchQuery->toSQL(); echo $sql;
foreach ($resources as $resource) {
//echo $resource->get('pagetitle') . '<br>';
$ids[] = $resource->get('id');
}
//echo '<br><br>';
//echo 'found'. count ($ids) '<br>';
//print_r($ids);
if(count($ids)==0){
$found = $mSearch2->Search($query);
$ids = array_merge($exact, array_keys($found));
}
$resources = implode(',', $ids);
if (empty($ids)) {
if ($returnIds) {
return '';
}
elseif (!empty($query)) {
$output = $modx->lexicon('mse2_err_no_results');
}
if (!empty($tplWrapper) && !empty($wrapIfEmpty)) {
$output = $mSearch2->pdoTools->getChunk(
$tplWrapper,
array(
'output' => $output,
'total' => 0,
'query' => $query,
'parents' => $modx->getPlaceholder($plPrefix.$parentsVar),
),
$fastMode
);
}
if ($modx->user->hasSessionContext('mgr') && !empty($showLog)) {
$output .= '<pre class="mSearchLog">' . print_r($mSearch2->pdoTools->getTime(), 1) . '</pre>';
}
if (!empty($toPlaceholder)) {
$modx->setPlaceholder($toPlaceholder, $output);
return;
}
else {
return $output;
}
}
}
}elseif (strpos($resources, '{') === 0) {
$found = $modx->fromJSON($resources);
$resources = implode(',', array_keys($found));
unset($scriptProperties['resources']);
}
//echo 'output 2<XQ2QXZ>';
/*----------------------------------------------------------------------------------*/
if (empty($returnIds)) {
// Joining tables
$leftJoin = array(
'mseIntro' => array(
'class' => 'mseIntro',
'alias' => 'Intro',
'on' => $class . '.id = Intro.resource'
)
);
// Fields to select
$resourceColumns = !empty($includeContent)
? $modx->getSelectColumns($class, $class)
: $modx->getSelectColumns($class, $class, '', array('content'), true);
$select = array(
$class => $resourceColumns,
'Intro' => 'intro'
);
$groupby = $class.'.id, Intro.intro';
} else {
$leftJoin = array();
$select = array($class . 'id');
$groupby = $class.'.id';
}
// Add custom parameters
foreach (array('leftJoin', 'select') as $v) {
if (!empty($scriptProperties[$v])) {
$tmp = $modx->fromJSON($scriptProperties[$v]);
if (is_array($tmp)) {
$$v = array_merge($$v, $tmp);
}
}
unset($scriptProperties[$v]);
}
// Default parameters
$default = array(
'class' => $class,
'leftJoin' => $leftJoin,
'select' => $select,
'groupby' => $groupby,
'return' => !empty($returnIds)
? 'ids'
: 'data',
'fastMode' => $fastMode,
'nestedChunkPrefix' => 'msearch2_',
);
if (!empty($resources)) {
$default['resources'] = is_array($resources)
? implode(',', $resources)
: $resources;
}
// Merge all properties and run!
$mSearch2->pdoTools->setConfig(array_merge($default, $scriptProperties), false);
$mSearch2->pdoTools->addTime('Query parameters are prepared.');
$rows = $mSearch2->pdoTools->run();
$log = '';
if ($modx->user->hasSessionContext('mgr') && !empty($showLog)) {
$log .= '<pre class="mSearchLog">' . print_r($mSearch2->pdoTools->getTime(), 1) . '</pre>';
}
// Processing results
if (!empty($returnIds)) {
$modx->setPlaceholder('mSearch.log', $log);
if (!empty($toPlaceholder)) {
$modx->setPlaceholder($toPlaceholder, $rows);
return '';
}
else {
return $rows;
}
}
elseif (!empty($rows) && is_array($rows)) {
$output = array();
foreach ($rows as $k => $row) {
// Processing main fields
$row['weight'] = isset($found[$row['id']]) ? $found[$row['id']] : '';
$row['intro'] = $mSearch2->Highlight($row['intro'], $query, $htagOpen, $htagClose);
$row['idx'] = $mSearch2->pdoTools->idx++;
$tplRow = $mSearch2->pdoTools->defineChunk($row);
$output[] .= empty($tplRow)
? $mSearch2->pdoTools->getChunk('', $row)
: $mSearch2->pdoTools->getChunk($tplRow, $row, $fastMode);
}
$mSearch2->pdoTools->addTime('Returning processed chunks');
if (!empty($toSeparatePlaceholders)) {
$output['log'] = $log;
$modx->setPlaceholders($output, $toSeparatePlaceholders);
}
else {
$output = implode($outputSeparator, $output) . $log;
}
}
else {
$output = $modx->lexicon('mse2_err_no_results') . $log;
}
// Return output
if (!empty($tplWrapper) && (!empty($wrapIfEmpty) || !empty($output))) {
$output = $mSearch2->pdoTools->getChunk(
$tplWrapper,
array(
'output' => $output,
'total' => $modx->getPlaceholder($mSearch2->pdoTools->config['totalVar']),
'query' => $modx->getPlaceholder($plPrefix.$queryVar),
'parents' => $modx->getPlaceholder($plPrefix.$parentsVar),
),
$fastMode
);
}
if (!empty($toPlaceholder)) {
$modx->setPlaceholder($toPlaceholder, $output);
}
else {
return $output;
}
From explanation to practice
MODX audit and PHP 8.3 migration preparationCustom code compatibility and technical website preparation.
Large product catalogue on MODX EvolutionExplore the project problem, solution and limitations.
MODX Evolution upgrade and PHP compatibilityA practical article about MODX and custom PHP code compatibility.
