aboutsummaryrefslogtreecommitdiff
path: root/src/Search
diff options
context:
space:
mode:
authorLaria Carolin Chabowski <laria@laria.me>2020-02-07 09:44:59 +0100
committerLaria Carolin Chabowski <laria@laria.me>2020-02-07 09:44:59 +0100
commit2eb5a432d2229788ce2fdb09f36c6f4bebdea813 (patch)
treeab57978bdda34c82b025b897cfb6825b1fd1e654 /src/Search
downloadmicropoly-2eb5a432d2229788ce2fdb09f36c6f4bebdea813.tar.gz
micropoly-2eb5a432d2229788ce2fdb09f36c6f4bebdea813.tar.bz2
micropoly-2eb5a432d2229788ce2fdb09f36c6f4bebdea813.zip
Initial commit
Diffstat (limited to 'src/Search')
-rw-r--r--src/Search/AbstractFTSExpr.php31
-rw-r--r--src/Search/CharSource.php33
-rw-r--r--src/Search/FTSExpr.php30
-rw-r--r--src/Search/FTSLogicOp.php46
-rw-r--r--src/Search/FTSNotExpr.php29
-rw-r--r--src/Search/LogicOp.php78
-rw-r--r--src/Search/NotOp.php32
-rw-r--r--src/Search/Pagination.php14
-rw-r--r--src/Search/ParseError.php9
-rw-r--r--src/Search/Parser.php295
-rw-r--r--src/Search/SQLSearchExpr.php11
-rw-r--r--src/Search/SearchExpr.php14
-rw-r--r--src/Search/SearchResult.php160
-rw-r--r--src/Search/TagExpr.php42
-rw-r--r--src/Search/TrueExpr.php25
15 files changed, 849 insertions, 0 deletions
diff --git a/src/Search/AbstractFTSExpr.php b/src/Search/AbstractFTSExpr.php
new file mode 100644
index 0000000..b72b1b6
--- /dev/null
+++ b/src/Search/AbstractFTSExpr.php
@@ -0,0 +1,31 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+abstract class AbstractFTSExpr implements SearchExpr
+{
+ abstract protected function fts4Query(): string;
+
+ public function toSQL(string $bindPrefix, bool $singleFTS): SQLSearchExpr
+ {
+ $sqlex = new SQLSearchExpr();
+
+ $sqlex->sql = $singleFTS
+ ? "nc.note_contents MATCH :{$bindPrefix}match"
+ : "n.content_row IN (
+ SELECT rowid
+ FROM note_contents
+ WHERE note_contents MATCH :{$bindPrefix}match
+ )";
+ $sqlex->bindings["{$bindPrefix}match"] = $this->fts4Query();
+
+ return $sqlex;
+ }
+
+ public function countFTSQueries(): int
+ {
+ return 1;
+ }
+} \ No newline at end of file
diff --git a/src/Search/CharSource.php b/src/Search/CharSource.php
new file mode 100644
index 0000000..165e538
--- /dev/null
+++ b/src/Search/CharSource.php
@@ -0,0 +1,33 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class CharSource
+{
+ private string $s;
+ private int $i = 0;
+ private int $len;
+
+ public function __construct(string $s)
+ {
+ $this->s = $s;
+ $this->len = mb_strlen($s);
+ }
+
+ public function getNext(): ?string
+ {
+ if ($this->i >= $this->len)
+ return null;
+
+ $c = mb_substr($this->s, $this->i, 1);
+ $this->i++;
+ return $c;
+ }
+
+ public function unget(): void
+ {
+ $this->i = max(0, $this->i - 1);
+ }
+} \ No newline at end of file
diff --git a/src/Search/FTSExpr.php b/src/Search/FTSExpr.php
new file mode 100644
index 0000000..1123cf3
--- /dev/null
+++ b/src/Search/FTSExpr.php
@@ -0,0 +1,30 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class FTSExpr extends AbstractFTSExpr
+{
+ private string $term;
+
+ public function __construct(string $term)
+ {
+ $this->term = $term;
+ }
+
+ public function getTerm(): string
+ {
+ return $this->term;
+ }
+
+ protected function fts4Query(): string
+ {
+ return '"' . str_replace('"', '""', $this->term) . '"';
+ }
+
+ public function toString(): string
+ {
+ return '"' . preg_replace_callback('/(["\\\\])/', fn($s) => "\\$s", $this->term) . '"';
+ }
+} \ No newline at end of file
diff --git a/src/Search/FTSLogicOp.php b/src/Search/FTSLogicOp.php
new file mode 100644
index 0000000..452f63b
--- /dev/null
+++ b/src/Search/FTSLogicOp.php
@@ -0,0 +1,46 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class FTSLogicOp extends AbstractFTSExpr
+{
+ private string $op;
+ private AbstractFTSExpr $a;
+ private AbstractFTSExpr $b;
+
+ /**
+ * FTSLogicOp constructor.
+ * @param string $op
+ * @param AbstractFTSExpr $a
+ * @param AbstractFTSExpr $b
+ */
+ public function __construct(string $op, AbstractFTSExpr $a, AbstractFTSExpr $b)
+ {
+ if (!LogicOp::checkOp($op))
+ throw new \DomainException("{$op} is not a valid operator");
+
+ $this->op = $op;
+ $this->a = $a;
+ $this->b = $b;
+ }
+
+ private const FTSOPS = [
+ LogicOp::OP_AND => "",
+ LogicOp::OP_OR => "OR",
+ ];
+
+ protected function fts4Query(): string
+ {
+ $ftsop = self::FTSOPS[$this->op];
+ assert($ftsop);
+
+ return "({$this->a->fts4Query()} {$ftsop} {$this->b->fts4Query()})";
+ }
+
+ public function toString(): string
+ {
+ return "({$this->a->toString()} FTS-{$this->op} {$this->b->toString()})";
+ }
+} \ No newline at end of file
diff --git a/src/Search/FTSNotExpr.php b/src/Search/FTSNotExpr.php
new file mode 100644
index 0000000..a4aa219
--- /dev/null
+++ b/src/Search/FTSNotExpr.php
@@ -0,0 +1,29 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class FTSNotExpr extends AbstractFTSExpr
+{
+ private AbstractFTSExpr $expr;
+
+ /**
+ * FTSNotExpr constructor.
+ * @param AbstractFTSExpr $expr
+ */
+ public function __construct(AbstractFTSExpr $expr)
+ {
+ $this->expr = $expr;
+ }
+
+ protected function fts4Query(): string
+ {
+ return "-{$this->expr->fts4Query()}";
+ }
+
+ public function toString(): string
+ {
+ return "(FTS-NOT {$this->expr->toString()})";
+ }
+} \ No newline at end of file
diff --git a/src/Search/LogicOp.php b/src/Search/LogicOp.php
new file mode 100644
index 0000000..85fb8fa
--- /dev/null
+++ b/src/Search/LogicOp.php
@@ -0,0 +1,78 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class LogicOp implements SearchExpr
+{
+ public const OP_AND = "and";
+ public const OP_OR = "or";
+
+ private const SQLOPS = [
+ self::OP_AND => "AND",
+ self::OP_OR => "OR",
+ ];
+
+ private string $op;
+ private SearchExpr $a;
+ private SearchExpr $b;
+
+ public function __construct(string $op, SearchExpr $a, SearchExpr $b)
+ {
+ if (!self::checkOp($op))
+ throw new \DomainException("{$op} is not a valid operator");
+
+ $this->op = $op;
+ $this->a = $a;
+ $this->b = $b;
+ }
+
+ public static function build(string $op, SearchExpr $a, SearchExpr $b): SearchExpr
+ {
+ return $a instanceof AbstractFTSExpr && $b instanceof AbstractFTSExpr
+ ? new FTSLogicOp($op, $a, $b)
+ : new self($op, $a, $b);
+ }
+
+ /**
+ * @param string $op
+ * @return bool
+ */
+ public static function checkOp(string $op): bool
+ {
+ return in_array($op, [
+ self::OP_AND,
+ self::OP_OR,
+ ]);
+ }
+
+ public function getA(): SearchExpr { return $this->a; }
+ public function getB(): SearchExpr { return $this->b; }
+ public function getOp(): string { return $this->op; }
+
+ public function toString(): string
+ {
+ return "({$this->a->toString()}) {$this->op} ({$this->b->toString()})";
+ }
+
+ public function toSQL($bindPrefix, bool $singleFTS): SQLSearchExpr
+ {
+ $sqlex = new SQLSearchExpr();
+
+ $a = $this->a->toSQL("a_$bindPrefix", $singleFTS);
+ $b = $this->b->toSQL("b_$bindPrefix", $singleFTS);
+ $sqlop = self::SQLOPS[$this->op];
+ assert($sqlop);
+
+ $sqlex->sql = "(({$a->sql}) {$sqlop} ({$b->sql}))";
+ $sqlex->bindings = array_merge($a->bindings, $b->bindings);
+
+ return $sqlex;
+ }
+
+ public function countFTSQueries(): int
+ {
+ return $this->a->countFTSQueries() + $this->b->countFTSQueries();
+ }
+} \ No newline at end of file
diff --git a/src/Search/NotOp.php b/src/Search/NotOp.php
new file mode 100644
index 0000000..35fcf1e
--- /dev/null
+++ b/src/Search/NotOp.php
@@ -0,0 +1,32 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class NotOp implements SearchExpr
+{
+ private SearchExpr $expr;
+
+ public function __construct(SearchExpr $expr)
+ {
+ $this->expr = $expr;
+ }
+
+ public function toString(): string
+ {
+ return "not ({$this->expr->toString()})";
+ }
+
+ public function toSQL(string $bindPrefix, bool $singleFTS): SQLSearchExpr
+ {
+ $sqlex = $this->expr->toSQL($bindPrefix, $singleFTS);
+ $sqlex->sql = "(NOT ({$sqlex->sql}))";
+ return $sqlex;
+ }
+
+ public function countFTSQueries(): int
+ {
+ return $this->expr->countFTSQueries();
+ }
+} \ No newline at end of file
diff --git a/src/Search/Pagination.php b/src/Search/Pagination.php
new file mode 100644
index 0000000..b4b2447
--- /dev/null
+++ b/src/Search/Pagination.php
@@ -0,0 +1,14 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class Pagination
+{
+ public const DEFAULT_PER_PAGE = 25;
+
+ private int $page = 1;
+
+
+} \ No newline at end of file
diff --git a/src/Search/ParseError.php b/src/Search/ParseError.php
new file mode 100644
index 0000000..1b987d7
--- /dev/null
+++ b/src/Search/ParseError.php
@@ -0,0 +1,9 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+use Exception;
+
+class ParseError extends Exception { } \ No newline at end of file
diff --git a/src/Search/Parser.php b/src/Search/Parser.php
new file mode 100644
index 0000000..a8efdfd
--- /dev/null
+++ b/src/Search/Parser.php
@@ -0,0 +1,295 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+use Generator;
+use Iterator;
+
+class Parser
+{
+ public const TOK_PAROPEN = "(";
+ public const TOK_PARCLOSE = ")";
+ public const TOK_TAG = "#";
+ public const TOK_WORD = '"';
+ public const TOK_OP = "op";
+ public const TOK_PROP = ":";
+
+ private static function iterChars(string $input): Iterator
+ {
+ for ($i = 0; $i < mb_strlen($input); $i++)
+ yield mb_substr($input, $i, 1);
+ }
+
+ /**
+ * @param string $input
+ * @return Iterator
+ * @throws ParseError
+ */
+ public static function tokenize(string $input): Iterator
+ {
+ $chars = new CharSource($input);
+ yield from self::tokenize_normal($chars);
+ }
+
+ private static function getItemAndAdvance(Iterator $input)
+ {
+ if (!$input->valid())
+ return null;
+ $out = $input->current();
+ $input->next();
+ return $out;
+ }
+
+ /**
+ * @return Iterator
+ * @throws ParseError
+ */
+ private static function tokenize_normal(CharSource $input): Iterator
+ {
+ $buf = "";
+
+ $yieldBufAndClear = function () use (&$buf) {
+ if ($buf !== "") {
+ switch ($buf) {
+ case "and":
+ case "or":
+ case "not":
+ yield [self::TOK_OP, $buf];
+ break;
+ default:
+ yield [self::TOK_WORD, $buf];
+ }
+ }
+ $buf = "";
+ };
+
+ for (;;) {
+ $c = $input->getNext();
+ if ($c === null) {
+ break;
+ }
+
+ switch ($c) {
+ case '\\':
+ $next = $input->getNext();
+ if ($next === null) {
+ $buf .= $c;
+ break 2;
+ }
+ $buf .= $next;
+ break;
+
+ case ' ':
+ case "\t":
+ yield from $yieldBufAndClear();
+ break;
+
+ case '"':
+ yield from $yieldBufAndClear();
+ yield from self::tokenize_string($input);
+ break;
+
+ case ':':
+ if ($buf !== "") {
+ yield [self::TOK_PROP, $buf];
+ $buf = "";
+ }
+ break;
+
+ case '(':
+ yield from $yieldBufAndClear();
+ yield [self::TOK_PAROPEN, null];
+ break;
+
+ case ')':
+ yield from $yieldBufAndClear();
+ yield [self::TOK_PARCLOSE, null];
+ break;
+
+ case '#':
+ yield from $yieldBufAndClear();
+ yield from self::tokenize_tag($input);
+ break;
+
+ default:
+ $buf .= $c;
+ }
+ }
+
+ yield from $yieldBufAndClear();
+ return;
+ }
+
+ /**
+ * @param string $input
+ * @return SearchExpr|null
+ * @throws ParseError
+ */
+ public static function parse(string $input): ?SearchExpr
+ {
+ $tokens = self::tokenize($input);
+
+ $stack = [];
+ $cur = null;
+ $binOp = null;
+ $negated = false;
+
+ $putExpr = function (SearchExpr $expr) use (&$cur, &$binOp, &$negated) {
+ if ($negated) {
+ $expr = new NotOp($expr);
+ }
+
+ $cur = $cur === null
+ ? $expr
+ : LogicOp::build($binOp ?? LogicOp::OP_AND, $cur, $expr);
+
+ $binOp = null;
+ $negated = false;
+ };
+
+ $setBinOp = function ($op) use (&$binOp) {
+ if ($binOp !== null)
+ throw new ParseError("Unexpected logic operator $op");
+
+ $binOp = $op;
+ };
+
+ for (;;) {
+ $token = self::getItemAndAdvance($tokens);
+ if ($token === null)
+ break;
+
+ [$ttyp, $tdata] = $token;
+
+ switch ($ttyp) {
+
+ case self::TOK_TAG:
+ $putExpr(new TagExpr($tdata));
+ break;
+ case self::TOK_OP:
+ switch ($tdata) {
+ case "and":
+ $setBinOp(LogicOp::OP_AND);
+ break;
+ case "or":
+ $setBinOp(LogicOp::OP_OR);
+ break;
+ case "not":
+ $negated = !$negated;
+ break;
+ default:
+ throw new \DomainException("Unexpected data for TOK_OP: $tdata");
+ }
+ break;
+ case self::TOK_WORD:
+ $putExpr(new FTSExpr($tdata));
+ break;
+ case self::TOK_PROP:
+ // TODO(laria): Implement this
+ throw new ParseError("Not yet supported");
+ case self::TOK_PAROPEN:
+ $stack[] = [$cur, $binOp, $negated];
+ $cur = $binOp = $negated = null;
+ break;
+ case self::TOK_PARCLOSE:
+ if (empty($stack))
+ throw new ParseError("Unexpected closing parenthesis");
+
+ $parContent = $cur;
+ [$cur, $binOp, $negated] = array_pop($stack);
+ $putExpr($parContent);
+ break;
+ }
+ }
+
+ if (!empty($stack))
+ throw new ParseError("Unclosed parenthesis");
+
+ return $cur;
+ }
+
+ /**
+ * @param CharSource $input
+ * @return Generator
+ * @throws ParseError
+ */
+ private static function tokenize_string(CharSource $input): Generator
+ {
+ $content = "";
+ for (;;) {
+ $c = $input->getNext();
+ if ($c === null)
+ throw new ParseError("Unclosed string encountered");
+
+ switch ($c) {
+ case '\\':
+ $next = $input->getNext();
+ if ($next === null)
+ throw new ParseError("Unclosed string encountered");
+
+ $content .= $next;
+ break;
+
+ case '"':
+ yield [self::TOK_WORD, $content];
+ return;
+
+ default:
+ $content .= $c;
+ }
+ }
+ }
+
+ /**
+ * @param CharSource $input
+ * @return Iterator
+ */
+ private static function tokenize_tag(CharSource $input): Iterator
+ {
+ $tag = "";
+
+ $yieldTag = function () use (&$tag) {
+ if ($tag === "")
+ yield [self::TOK_WORD, "#"];
+ else
+ yield [self::TOK_TAG, $tag];
+ };
+
+ for (;;) {
+ $c = $input->getNext();
+ if ($c === null) {
+ yield from $yieldTag();
+ return;
+ }
+
+ switch ($c) {
+ case '\\':
+ $next = $input->getNext();
+ if ($c === null) {
+ $tag .= '\\';
+ yield [self::TOK_TAG, $tag];
+ return;
+ }
+ $tag .= $next;
+ break;
+
+ case ' ':
+ case "\t":
+ yield from $yieldTag();
+ return;
+
+ case '(':
+ case ')':
+ case '#':
+ $input->unget();
+ yield from $yieldTag();
+ return;
+
+ default:
+ $tag .= $c;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Search/SQLSearchExpr.php b/src/Search/SQLSearchExpr.php
new file mode 100644
index 0000000..76306ce
--- /dev/null
+++ b/src/Search/SQLSearchExpr.php
@@ -0,0 +1,11 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class SQLSearchExpr
+{
+ public string $sql;
+ public array $bindings = [];
+} \ No newline at end of file
diff --git a/src/Search/SearchExpr.php b/src/Search/SearchExpr.php
new file mode 100644
index 0000000..fbf2a40
--- /dev/null
+++ b/src/Search/SearchExpr.php
@@ -0,0 +1,14 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+interface SearchExpr
+{
+ public function toString(): string;
+
+ public function toSQL(string $bindPrefix, bool $singleFTS): SQLSearchExpr;
+
+ public function countFTSQueries(): int;
+} \ No newline at end of file
diff --git a/src/Search/SearchResult.php b/src/Search/SearchResult.php
new file mode 100644
index 0000000..1abbb86
--- /dev/null
+++ b/src/Search/SearchResult.php
@@ -0,0 +1,160 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+use LogicException;
+use Micropoly\DbQuery;
+use Micropoly\Esc;
+use Micropoly\Models\Note;
+use SQLite3;
+
+class SearchResult
+{
+ private Note $note;
+ private array $highlights = [];
+
+ private function __construct(Note $note, array $highlights)
+ {
+ $this->note = $note;
+ $this->highlights = $highlights;
+ }
+
+ /**
+ * @param SQLite3 $db
+ * @param SearchExpr $expr
+ * @return self[]
+ */
+ public static function search(SQLite3 $db, SearchExpr $expr): array
+ {
+ return $expr->countFTSQueries() === 1
+ ? self::searchFTS($db, $expr)
+ : self::searchComplex($db, $expr);
+ }
+
+ private static function searchComplex(SQLite3 $db, SearchExpr $expr): array
+ {
+ $sqlSearchExpr = $expr->toSQL("", false);
+
+ $query = new DbQuery("
+ SELECT
+ n.id
+ FROM notes n
+ INNER JOIN note_contents nc
+ ON nc.rowid = n.content_row
+ WHERE {$sqlSearchExpr->sql}
+ ");
+
+ foreach ($sqlSearchExpr->bindings as $k => $v)
+ $query->bind($k, $v);
+
+ $ids = array_map(fn ($row) => $row[0], $query->fetchRows($db));
+ $notes = Note::byIds($db, $ids);
+ return array_map(fn ($note) => new self($note, []), $notes);
+ }
+
+ private static function highlightRangeContains(array $range, int $point): bool
+ {
+ [$start, $end] = $range;
+ return $start <= $point && $point <= $end;
+ }
+
+ private static function areHighlightsOverlapping(array $a, array $b): bool
+ {
+ [$aStart, $aEnd] = $a;
+ [$bStart, $bEnd] = $b;
+
+ return self::highlightRangeContains($a, $bStart)
+ || self::highlightRangeContains($a, $bEnd)
+ || self::highlightRangeContains($b, $aStart)
+ || self::highlightRangeContains($b, $aEnd);
+ }
+
+ private static function parseOffsetsToHighlights(string $offsets): array
+ {
+ $offsets = explode(" ", $offsets);
+ $offsets = array_map("intval", $offsets);
+
+ $phraseMatches = count($offsets) / 4;
+
+ $highlights = [];
+ for ($i = 0; $i < $phraseMatches; $i++) {
+ $off = $offsets[$i * 4 + 2];
+ $len = $offsets[$i * 4 + 3];
+
+ if ($off < 0 || $len === 0)
+ continue;
+
+ $highlights[] = [$off, $off+$len-1];
+ }
+
+ usort($highlights, fn ($a, $b) => ($a[0] <=> $b[0]) ?: ($b[1] <=> $a[1]));
+
+ // merge overlapping areas
+ for ($i = count($highlights)-1; $i >= 0; $i--) {
+ for ($j = $i-1; $j >= 0; $j--) {
+ if (self::areHighlightsOverlapping($highlights[$i], $highlights[$j])) {
+ [$iStart, $iEnd] = $highlights[$i];
+ [$jStart, $jEnd] = $highlights[$j];
+
+ $highlights[$j] = [min($iStart, $jStart), max($iEnd, $jEnd)];
+ unset($highlights[$i]);
+ break;
+ }
+ }
+ }
+
+ return array_merge($highlights); // array_merge here renumbers the keys
+ }
+
+ private static function searchFTS(SQLite3 $db, SearchExpr $expr)
+ {
+ $sqlSearchExpr = $expr->toSQL("", true);
+ $query = new DbQuery("
+ SELECT
+ n.id,
+ offsets(nc.note_contents) AS offsets
+ FROM notes n
+ INNER JOIN note_contents nc
+ ON nc.rowid = n.content_row
+ WHERE {$sqlSearchExpr->sql}
+ ");
+ foreach ($sqlSearchExpr->bindings as $k => $v)
+ $query->bind($k, $v);
+
+
+ $offsets = $query->fetchIndexedValues($db, "offsets", "id");
+
+ $notes = Note::byIds($db, array_keys($offsets));
+
+ $out = [];
+ foreach ($offsets as $id => $offString) {
+ if (!isset($notes[$id]))
+ throw new LogicException("Note '{$id}' not loaded but found?");
+
+ $out[] = new self($notes[$id], self::parseOffsetsToHighlights($offString));
+ }
+
+ return $out;
+ }
+
+ public function renderHighlightedContent(): string
+ {
+ $out = "";
+ $content = $this->note->getContent();
+ $lastOff = 0;
+ foreach ($this->highlights as [$start, $end]) {
+ $out .= Esc::e(substr($content, $lastOff, $start - $lastOff), Esc::HTML_WITH_BR);
+ $out .= '<b>' . Esc::e(substr($content, $start, $end - $start + 1), Esc::HTML_WITH_BR) . '</b>';
+
+ $lastOff = $end + 1;
+ }
+
+ $out .= Esc::e(substr($content, $lastOff), Esc::HTML_WITH_BR);
+
+ return $out;
+ }
+
+ public function getNote(): Note { return $this->note; }
+} \ No newline at end of file
diff --git a/src/Search/TagExpr.php b/src/Search/TagExpr.php
new file mode 100644
index 0000000..b117bbe
--- /dev/null
+++ b/src/Search/TagExpr.php
@@ -0,0 +1,42 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class TagExpr implements SearchExpr
+{
+ private string $tag;
+
+ public function __construct(string $tag)
+ {
+ $this->tag = $tag;
+ }
+
+ public function getTag(): string { return $this->tag; }
+
+ public function toString(): string
+ {
+ return "#{$this->tag}";
+ }
+
+ public function toSQL(string $bindPrefix, bool $singleFTS): SQLSearchExpr
+ {
+ $sqlex = new SQLSearchExpr();
+
+ $sqlex->sql = "EXISTS (
+ SELECT 1
+ FROM tags t
+ WHERE t.tag = :{$bindPrefix}tag
+ AND t.note_id = n.id
+ )";
+ $sqlex->bindings["{$bindPrefix}tag"] = $this->tag;
+
+ return $sqlex;
+ }
+
+ public function countFTSQueries(): int
+ {
+ return 0;
+ }
+} \ No newline at end of file
diff --git a/src/Search/TrueExpr.php b/src/Search/TrueExpr.php
new file mode 100644
index 0000000..5f25c7e
--- /dev/null
+++ b/src/Search/TrueExpr.php
@@ -0,0 +1,25 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class TrueExpr implements SearchExpr
+{
+ public function toString(): string
+ {
+ return "<TrueExpr>";
+ }
+
+ public function toSQL(string $bindPrefix, bool $singleFTS): SQLSearchExpr
+ {
+ $sqlSearchExpr = new SQLSearchExpr();
+ $sqlSearchExpr->sql = "1";
+ return $sqlSearchExpr;
+ }
+
+ public function countFTSQueries(): int
+ {
+ return 0;
+ }
+} \ No newline at end of file