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