CaiWuXiTong/app/Traits/SearchString.php
WuYongJie 9595267ee7
Some checks failed
Tests / PHP 8.1 (push) Has been cancelled
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
chore: 导入 Akaunting 初始源码与团队协作规则
2026-07-20 18:20:25 +08:00

197 lines
6.4 KiB
PHP

<?php
namespace App\Traits;
trait SearchString
{
/**
* Get the value of a name in search string.
* Supports compact form (no spaces around operator) and spaced form.
*
* Examples (compact):
* search=type:customer year:2020 account_id:20
* search=issued_at>=2021-02-01 issued_at<=2021-02-10
*
* Examples (spaced):
* search=age > 30
* search=title : "Hello World"
*
* Returns a string for single-value operators (:, =) and an array when the
* same column appears multiple times (e.g. range queries with > / <).
* Surrounding quotes are stripped from returned values.
*/
public function getSearchStringValue(
string $name,
string $default = '',
string $input = '',
): string|array {
$value = $default;
$input = $input ?: request('search', '');
// Normalise spaced operators (e.g. "age > 30") into compact form ("age>30")
// so the rest of the parsing logic only needs to handle one format.
$input = preg_replace('/\b(' . preg_quote($name, '/') . ')\s*(>=|<=|>|<|=|:)\s*/', '$1$2', $input);
$columns = explode(' ', $input);
foreach ($columns as $column) {
// Operators ordered longest-first to avoid partial matches (>= before >).
$variable = preg_split('/(>=|<=|>|<|=|:)/', $column, 2);
if ($name === 'searchable' && count($variable) === 1 && preg_match('/^".*"$/', $variable[0])) {
return trim($variable[0], '"');
}
if (empty($variable[0]) || $variable[0] !== $name || ! isset($variable[1]) || $variable[1] === '') {
continue;
}
$extracted = trim($variable[1], '"\'');
if (str_contains($column, ':')) {
return $extracted;
}
if (! is_array($value)) {
$value = [];
}
$value[] = $extracted;
}
return $value;
}
/**
* Get the operator for a named column in the search string, mapped to an Eloquent-compatible operator.
* The `:` operator maps to `=` (exact match).
* The `not` prefix inverts the operator (e.g. `not amount>100` → `<=`).
*
* Examples without `not`:
* search=amount>=100 → '>='
* search=amount<=100 → '<='
* search=amount>100 → '>'
* search=amount<100 → '<'
* search=amount:100 → '='
* search=amount=100 → '='
*
* Examples with `not`:
* search=not amount>=100 → '<'
* search=not amount<=100 → '>'
* search=not amount>100 → '<='
* search=not amount<100 → '>='
* search=not amount:100 → '!='
* search=not amount=100 → '!='
*/
public function getSearchStringOperator(
string $name,
string $default = '=',
string $input = '',
): string {
$input = $input ?: request('search', '');
$columns = explode(' ', $input);
foreach ($columns as $index => $column) {
if (! str_starts_with($column, $name)) {
continue;
}
$rest = substr($column, strlen($name));
if (! preg_match('/^(>=|<=|>|<|=|:)/', $rest, $matches)) {
continue;
}
$operator = $matches[1] === ':' ? '=' : $matches[1];
$negated = isset($columns[$index - 1]) && $columns[$index - 1] === 'not';
if (! $negated) {
return $operator;
}
return match ($operator) {
'>=' => '<',
'>' => '<=',
'<=' => '>',
'<' => '>=',
default => '!=',
};
}
return $default;
}
/**
* Strip tokens from a search string whose keys are not recognised columns
* for the given model class. Tokens whose keys appear in neither the model's
* search-string config nor the default keywords / columns are removed before
* the string is passed to usingSearchString().
*
* Falls back to returning $input unchanged when the model has no config entry.
*/
public function stripUnknownSearchStringTokens(
string $input,
string $model_class,
): string {
$model_config_columns = config('search-string.' . $model_class . '.columns', null);
if ($model_config_columns === null) {
return $input;
}
$valid_columns = array_unique(array_merge(
array_keys($model_config_columns),
array_values(config('search-string.default.keywords', [])),
array_keys(config('search-string.default.columns', [])),
));
// Collect key aliases from column configs.
// A 'key' may be a plain string alias (e.g. 'invoiced_at') or a regex
// pattern (e.g. '/^(invoiced_at|billed_at)$/'). Plain aliases are
// merged into $valid_columns; regex patterns are kept separately and
// tested with preg_match inside the callback below.
$key_patterns = [];
foreach ($model_config_columns as $column_config) {
if (! is_array($column_config) || ! isset($column_config['key'])) {
continue;
}
$key = $column_config['key'];
if (is_string($key) && strlen($key) >= 2 && $key[0] === '/' && $key[-1] === '/') {
$key_patterns[] = $key;
} elseif (is_string($key)) {
$valid_columns[] = $key;
}
}
$valid_columns = array_unique($valid_columns);
// Match optional "not " prefix + key + operator + value (quoted or unquoted)
$input = preg_replace_callback(
pattern: '/\b(?:not\s+)?(\w+)\s*(?:>=|<=|>|<|=|:)\s*(?:"[^"]*"|\S+)/',
callback: function (array $matches) use ($valid_columns, $key_patterns): string {
$token = $matches[1];
if (in_array($token, $valid_columns, strict: true)) {
return $matches[0];
}
foreach ($key_patterns as $pattern) {
if (preg_match($pattern, $token)) {
return $matches[0];
}
}
return '';
},
subject: $input,
);
return trim(preg_replace('/\s+/', ' ', $input));
}
}