packages/ztd-query-sqlite/src/Rewrite/FullText/MatchExpressionRewriter.php
1<?php
2
3declare(strict_types=1);
4
5namespace ZtdQuery\Platform\Sqlite\Rewrite\FullText;
6
7use ZtdQuery\Platform\Sqlite\Sql\SqliteIdentifierQuoter;
8use ZtdQuery\Platform\Sqlite\Sql\SqliteParser;
9use ZtdQuery\Sql\SqlToken;
10use ZtdQuery\Sql\SqlTokenKind;
11use ZtdQuery\Sql\SqlTokenStream;
12
13/**
14 * Projects MATCH expressions onto shadowed full-text table columns.
15 *
16 * @phpstan-import-type TableContext from \ZtdQuery\Platform\Sqlite\Rewrite\FullText\SqliteFullTextSearchRewriter
17 *
18 * @visibility ZtdQuery\Platform\Sqlite
19 */
20final class MatchExpressionRewriter
21{
22 /**
23 * Binds the collaborators used by this operation.
24 */
25 public function __construct(
26 private SqliteParser $parser,
27 private SqliteIdentifierQuoter $quoter
28 ) {
29 }
30
31 /**
32 * @param array<string, TableContext> $tables
33 * @return array{start: int, end: int, replacement: string}|null
34 */
35 public function expressionEdit(SqlTokenStream $stream, SqlToken $operator, array $tables): ?array
36 {
37 if ($operator->isKeyword('MATCH')) {
38 $allowsColumnName = true;
39 } elseif ($operator->kind === SqlTokenKind::Symbol && $operator->text === '=') {
40 $allowsColumnName = false;
41 } else {
42 return null;
43 }
44
45 $left = $stream->significantTokenBefore($operator);
46 if ($left === null || !self::isIdentifier($left)) {
47 return null;
48 }
49 $query = $stream->significantTokenAfter($operator);
50 if ($query === null || !self::isQueryExpression($query)) {
51 return null;
52 }
53
54 $name = $this->parser->unquoteIdentifier($left->text);
55 $columns = (new FullTextColumns())->tableColumns($name, $tables);
56 if ($columns === null && $allowsColumnName) {
57 $columns = (new FullTextColumns())->matchingColumn($name, $tables);
58 }
59 if ($columns === null || $columns === []) {
60 return null;
61 }
62
63 $documentParts = array_map(
64 fn (string $column): string => "COALESCE(CAST({$this->quoter->quote($column)} AS TEXT), '')",
65 $columns,
66 );
67 $document = 'LOWER(' . implode(" || ' ' || ", $documentParts) . ')';
68 $needle = "LOWER(NULLIF(TRIM(CAST(({$query->text}) AS TEXT)), ''))";
69
70 return [
71 'start' => $left->offset,
72 'end' => $query->endOffset(),
73 'replacement' => "(INSTR($document, $needle) > 0)",
74 ];
75 }
76
77 /**
78 * Projects MATCH expressions onto shadowed full-text table columns.
79 */
80 public static function isIdentifier(SqlToken $token): bool
81 {
82 return match ($token->kind) {
83 SqlTokenKind::Word, SqlTokenKind::QuotedIdentifier => true,
84 SqlTokenKind::String, SqlTokenKind::Number, SqlTokenKind::Parameter,
85 SqlTokenKind::Symbol, SqlTokenKind::Comment, SqlTokenKind::Whitespace => false,
86 };
87 }
88
89 /**
90 * Projects MATCH expressions onto shadowed full-text table columns.
91 */
92 public static function isQueryExpression(SqlToken $token): bool
93 {
94 return in_array($token->kind, [SqlTokenKind::String, SqlTokenKind::Parameter], true);
95 }
96}
97