Skip to content

Commit 855c4e7

Browse files
authored
feat(fts): honor per-field searchable flag (#58)
SqliteItemRepository::syncFts() now consults the FieldRepository for the per-category set of `searchable: true` field names and filters the `items_fts.body` write accordingly. FullTextSearch:: rebuild() does the same in a single pre-pass over the fields table plus per-item iteration. Migration 0005_searchable_defaults.sql preserves existing FTS coverage on upgrade by promoting all `text/longtext/editor/slug` field rows to searchable=1 (matches the 2.2.0 factory defaults). Verified against a copy of the live Scriptor schema: all 8 text-typed fields promote; password + fileupload stay at 0. Backward compatibility: SqliteItemRepository's third constructor arg (?FieldRepository) is optional. The 2.0/2.1 two-arg signature keeps working — falls back to "index everything" with a one-time E_USER_DEPRECATED notice on first FTS write. Body composition extracted to FtsBody::compose() so the per-save writer and the bulk rebuilder cannot drift on the format. The plan called for per-process category-fields caching with event- based invalidation; revisited and dropped: per-save fetch from local SQLite is sub-millisecond, and the no-cache path can't go stale in long-running CLI processes. New test file SearchableFlagTest covers: opt-out per save, structural name+label always indexed, update refreshes set, rebuild respects flag, per-category isolation, flip-then-rebuild contract, migration 0005 row-level effect, legacy constructor deprecation.
1 parent d44bdea commit 855c4e7

7 files changed

Lines changed: 502 additions & 24 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- 2.2.0 — the per-field `searchable` flag becomes load-bearing.
2+
--
3+
-- Prior to 2.2.0, the FTS5 writer ignored `searchable` and flattened every
4+
-- string/numeric value from `items.data` into `items_fts.body`. The
5+
-- `Field` constructor defaulted `searchable` to false, so an honest read
6+
-- of the column on an existing install would say "no field is searchable"
7+
-- — which, applied as a behavioral switch, would silently drop ALL FTS
8+
-- body coverage on upgrade.
9+
--
10+
-- This migration preserves the de-facto coverage for prose-typed content
11+
-- so existing installs keep finding the same items via search. The four
12+
-- promoted types are exactly those whose 2.2.0 factories
13+
-- (`Field::text|longText|editor|slug()`) default to `searchable: true`.
14+
--
15+
-- Side effects (all deliberate, documented in CHANGELOG):
16+
-- * `password` fields stop being indexed (was a bcrypt hash anyway).
17+
-- * `fileupload`/`imageupload`/`filepicker` paths stop being indexed.
18+
-- * `integer`/`decimal`/`money`/`datepicker`/`checkbox`/`dropdown`/
19+
-- `hidden`/`arrayList` values stop being indexed.
20+
--
21+
-- After this migration, callers should run `vendor/bin/imanager fts:rebuild`
22+
-- so the body column actually drops the now-excluded values. The flag is
23+
-- already honored by per-save syncFts from this release onward; the
24+
-- rebuild reconciles pre-existing rows.
25+
26+
UPDATE fields
27+
SET searchable = 1
28+
WHERE searchable = 0
29+
AND type IN ('text', 'longtext', 'editor', 'slug');

src/Search/FtsBody.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Imanager\Search;
6+
7+
/**
8+
* Composes the `body` column written into `items_fts`.
9+
*
10+
* Centralized so both the per-save writer
11+
* ({@see \Imanager\Storage\Sqlite\SqliteItemRepository::syncFts()}) and the
12+
* bulk rebuilder ({@see FullTextSearch::rebuild()}) flatten data the same
13+
* way — drift between the two would silently corrupt search results.
14+
*/
15+
final readonly class FtsBody
16+
{
17+
/**
18+
* Flatten an item's structural + dynamic fields into the single string
19+
* stored in `items_fts.body`.
20+
*
21+
* `$name` and `$label` are structural columns on the items table and
22+
* are always concatenated in. `$data` is the dynamic per-field bag.
23+
*
24+
* When `$allowedKeys` is non-null, only top-level entries in `$data`
25+
* whose key appears in the list are walked — that's how the per-field
26+
* `searchable` flag (honored from 2.2.0) takes effect. When `null`, the
27+
* whole `$data` blob is flattened (legacy behavior, retained for the
28+
* 2.0/2.1 constructor signature of `SqliteItemRepository`).
29+
*
30+
* @param array<string, mixed> $data
31+
* @param list<string>|null $allowedKeys
32+
*/
33+
public static function compose(
34+
?string $name,
35+
?string $label,
36+
array $data,
37+
?array $allowedKeys,
38+
): string {
39+
if ($allowedKeys !== null) {
40+
$data = array_intersect_key($data, array_flip($allowedKeys));
41+
}
42+
43+
$parts = [];
44+
array_walk_recursive($data, static function (mixed $value) use (&$parts): void {
45+
if (\is_string($value) || \is_int($value) || \is_float($value)) {
46+
$parts[] = (string) $value;
47+
}
48+
});
49+
50+
return ($name ?? '') . ' ' . ($label ?? '') . ' ' . implode(' ', $parts);
51+
}
52+
}

src/Search/FullTextSearch.php

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,18 +106,64 @@ public function count(string $query, ?int $categoryId = null): int
106106

107107
/**
108108
* Drop and rebuild the FTS index from scratch. Useful as a CLI op when
109-
* tokenizer settings or migration content changes.
109+
* tokenizer settings or migration content changes, and the canonical
110+
* step after upgrading to 2.2.0 so the body column drops values whose
111+
* field's `searchable` flag is now false.
112+
*
113+
* The rebuild iterates items in PHP rather than running a single bulk
114+
* INSERT…SELECT because the per-category set of searchable field names
115+
* varies per row. This is a CLI op, not a hot path — per-row iteration
116+
* is acceptable at the install sizes iManager realistically targets.
110117
*/
111118
public function rebuild(): void
112119
{
113120
try {
121+
// Per-category set of searchable field names. One query, used
122+
// for the entire rebuild.
123+
$allowedByCategory = [];
124+
$fieldsStmt = $this->connection->query(
125+
'SELECT category_id, name FROM fields WHERE searchable = 1',
126+
);
127+
if ($fieldsStmt !== false) {
128+
foreach ($fieldsStmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
129+
$allowedByCategory[(int) $row['category_id']][] = (string) $row['name'];
130+
}
131+
}
132+
114133
$this->connection->exec('DELETE FROM items_fts');
115-
$this->connection->exec(
116-
'INSERT INTO items_fts(rowid, name, label, body) '
117-
. 'SELECT i.id, IFNULL(i.name, \'\'), IFNULL(i.label, \'\'), '
118-
. 'IFNULL(i.name, \'\') || \' \' || IFNULL(i.label, \'\') || \' \' || IFNULL(i.data, \'\') '
119-
. 'FROM items i',
134+
135+
$itemsStmt = $this->connection->query(
136+
'SELECT id, category_id, name, label, data FROM items',
137+
);
138+
if ($itemsStmt === false) {
139+
return;
140+
}
141+
142+
$insert = $this->connection->prepare(
143+
'INSERT INTO items_fts (rowid, name, label, body) '
144+
. 'VALUES (:id, :name, :label, :body)',
120145
);
146+
147+
foreach ($itemsStmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
148+
$categoryId = (int) $row['category_id'];
149+
$allowed = $allowedByCategory[$categoryId] ?? [];
150+
151+
$rawData = $row['data'] !== null ? (string) $row['data'] : '';
152+
$data = $rawData !== '' ? json_decode($rawData, true) : [];
153+
if (! \is_array($data)) {
154+
$data = [];
155+
}
156+
157+
$name = $row['name'] !== null ? (string) $row['name'] : '';
158+
$label = $row['label'] !== null ? (string) $row['label'] : '';
159+
160+
$insert->execute([
161+
':id' => (int) $row['id'],
162+
':name' => $name,
163+
':label' => $label,
164+
':body' => FtsBody::compose($name, $label, $data, $allowed),
165+
]);
166+
}
121167
} catch (\PDOException $e) {
122168
throw StorageException::fromPdo($e, 'Full-text index rebuild failed');
123169
}

src/Storage/Sqlite/SqliteItemRepository.php

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
use Imanager\Query\Clause;
1515
use Imanager\Query\Direction;
1616
use Imanager\Query\Query;
17+
use Imanager\Search\FtsBody;
18+
use Imanager\Storage\FieldRepository;
1719
use Imanager\Storage\ItemRepository;
1820
use Psr\EventDispatcher\EventDispatcherInterface;
1921

@@ -39,11 +41,21 @@
3941

4042
private readonly EventDispatcherInterface $events;
4143

44+
/**
45+
* Optional repository used to look up the per-field `searchable`
46+
* flag. When `null` (the 2.0/2.1 constructor signature), syncFts
47+
* indexes every value — preserving legacy behavior for direct
48+
* callers, with a one-time deprecation notice on first FTS write.
49+
*/
50+
private readonly ?FieldRepository $fields;
51+
4252
public function __construct(
4353
private \PDO $connection,
4454
?EventDispatcherInterface $events = null,
55+
?FieldRepository $fields = null,
4556
) {
4657
$this->events = $events ?? new NullEventDispatcher();
58+
$this->fields = $fields;
4759
}
4860

4961
public function find(int $id): ?Item
@@ -118,7 +130,7 @@ public function save(Item $item): Item
118130
}
119131

120132
$newId = (int) $this->connection->lastInsertId();
121-
$this->syncFts($newId, $item->name, $item->label, $item->data->toArray());
133+
$this->syncFts($newId, $item->categoryId, $item->name, $item->label, $item->data->toArray());
122134

123135
$created_item = new Item(
124136
id: $newId,
@@ -161,7 +173,7 @@ public function save(Item $item): Item
161173
throw self::translatePdoException($e);
162174
}
163175

164-
$this->syncFts($item->id, $item->name, $item->label, $item->data->toArray());
176+
$this->syncFts($item->id, $item->categoryId, $item->name, $item->label, $item->data->toArray());
165177

166178
$updated = new Item(
167179
id: $item->id,
@@ -201,17 +213,17 @@ public function delete(int $id): void
201213
}
202214

203215
/**
204-
* Insert-or-replace the FTS index row for `$id`. Body is a flattened
205-
* concatenation of all string / numeric values in `$data` so search
206-
* matches across every dynamic field — see the `0002_fts.sql` migration
207-
* comment for the rationale (hybrid index, post-Phase-8 we'll respect
208-
* the per-field `searchable` flag once a use case asks for opt-out).
216+
* Insert-or-replace the FTS index row for `$id`. When a
217+
* `FieldRepository` was wired into this repository, only the fields
218+
* whose `searchable` flag is true are written to the body; otherwise
219+
* (the legacy 2.0/2.1 constructor signature) every dynamic value goes
220+
* in and a one-time deprecation notice fires.
209221
*
210222
* @param array<string, mixed> $data
211223
*/
212-
private function syncFts(int $id, ?string $name, ?string $label, array $data): void
224+
private function syncFts(int $id, int $categoryId, ?string $name, ?string $label, array $data): void
213225
{
214-
$body = ($name ?? '') . ' ' . ($label ?? '') . ' ' . self::flattenForSearch($data);
226+
$body = FtsBody::compose($name, $label, $data, $this->searchableKeysFor($categoryId));
215227

216228
$delete = $this->connection->prepare('DELETE FROM items_fts WHERE rowid = :id');
217229
$delete->execute([':id' => $id]);
@@ -228,17 +240,44 @@ private function syncFts(int $id, ?string $name, ?string $label, array $data): v
228240
}
229241

230242
/**
231-
* @param array<string, mixed> $data
243+
* Return the list of field names whose `searchable` flag is true for
244+
* `$categoryId`, or `null` when no `FieldRepository` was wired (legacy
245+
* 2.0/2.1 signature — fall back to "index everything"). The first such
246+
* fall-through emits an `E_USER_DEPRECATED` notice once per process so
247+
* external integrators get a heads-up without breaking.
248+
*
249+
* Each call re-queries the fields table. The query is local SQLite
250+
* (sub-millisecond for the dozens of fields per category iManager
251+
* realistically targets), and skipping the cache avoids staleness in
252+
* long-running CLI processes that mutate the schema mid-run.
253+
*
254+
* @return list<string>|null
232255
*/
233-
private static function flattenForSearch(array $data): string
256+
private function searchableKeysFor(int $categoryId): ?array
234257
{
235-
$parts = [];
236-
array_walk_recursive($data, static function (mixed $value) use (&$parts): void {
237-
if (\is_string($value) || \is_int($value) || \is_float($value)) {
238-
$parts[] = (string) $value;
258+
if ($this->fields === null) {
259+
static $warned = false;
260+
if (! $warned) {
261+
$warned = true;
262+
@trigger_error(
263+
'SqliteItemRepository was constructed without a FieldRepository — '
264+
. 'FTS will index every field value (legacy 2.0/2.1 behavior). Pass '
265+
. 'the FieldRepository into the third constructor argument to honor '
266+
. 'per-field searchable flags. The no-arg form will become an error '
267+
. 'in 3.0.',
268+
\E_USER_DEPRECATED,
269+
);
270+
}
271+
return null;
272+
}
273+
274+
$keys = [];
275+
foreach ($this->fields->findByCategory($categoryId) as $field) {
276+
if ($field->searchable) {
277+
$keys[] = $field->name;
239278
}
240-
});
241-
return implode(' ', $parts);
279+
}
280+
return $keys;
242281
}
243282

244283
public function query(Query $query): array

src/Storage/Sqlite/SqliteStorage.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public function fields(): FieldRepository
4949

5050
public function items(): ItemRepository
5151
{
52-
return new SqliteItemRepository($this->connection, $this->events);
52+
return new SqliteItemRepository($this->connection, $this->events, $this->fields());
5353
}
5454

5555
public function files(): FileRepository

tests/Unit/Search/FullTextSearchTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Imanager\Tests\Unit\Search;
66

77
use Imanager\Domain\Category;
8+
use Imanager\Domain\Field;
89
use Imanager\Domain\Item;
910
use Imanager\Exception\StorageException;
1011
use Imanager\Search\FullTextSearch;
@@ -35,6 +36,12 @@ protected function setUp(): void
3536
$this->blogId = $blog->id;
3637
$this->newsId = $news->id;
3738

39+
// Declare the `body` field on both categories — Field::longText()
40+
// defaults to searchable:true (2.2.0+), which is what these tests
41+
// need so per-save syncFts writes body content into FTS.
42+
$this->storage->fields()->ensure(Field::longText($this->blogId, 'body', 'Body'));
43+
$this->storage->fields()->ensure(Field::longText($this->newsId, 'body', 'Body'));
44+
3845
$this->storage->items()->save(new Item(
3946
id: null,
4047
categoryId: $this->blogId,

0 commit comments

Comments
 (0)