Skip to content

Commit d44bdea

Browse files
authored
feat(field): per-type searchable defaults for factories (#57)
Field::text / longText / editor / slug now return searchable:true by default. Every other factory returns searchable:false. The constructor default stays false to keep direct construction backward-compatible. No behavior change to FTS indexing yet — SqliteItemRepository still ignores the flag. The follow-up PR makes it load-bearing plus a migration that promotes existing text-typed rows.
1 parent 2ce9d5f commit d44bdea

3 files changed

Lines changed: 85 additions & 49 deletions

File tree

src/Domain/Field.php

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,23 @@
2020
* + fluent setters over the long named-argument constructor:
2121
*
2222
* Field::text($categoryId, 'title', 'Title')
23-
* ->required()->indexed()->searchable()->maxLength(200);
23+
* ->required()->indexed()->maxLength(200);
2424
*
2525
* Each setter returns a new `Field` (immutable value-object semantics
2626
* preserved — no mutable builder, no two-phase init).
27+
*
28+
* ## Flag semantics
29+
*
30+
* - `$indexed` — when true, the storage layer creates a SQLite generated
31+
* column over `json_extract(data, '$.<name>')` so equality/range
32+
* predicates on this field avoid a JSON-scan.
33+
* - `$searchable` — when true, this field's value is written into the
34+
* `items_fts.body` FTS5 column on every save. False means the value is
35+
* excluded from full-text search. Honored from 2.2.0 onward; the
36+
* factories pick sensible per-type defaults (prose-typed fields default
37+
* to true; passwords, numeric/date/structured/file fields default to
38+
* false). `name` and `label` are structural columns on the FTS table and
39+
* are always indexed regardless of this flag.
2740
*/
2841
final readonly class Field
2942
{
@@ -63,87 +76,90 @@ public function __construct(
6376

6477
// ---------------------------------------------------------------------
6578
// Static factories — one per FieldType case. All return a fresh
66-
// (id = null) Field with default flags + empty config.
79+
// (id = null) Field with an empty config and per-type `searchable`
80+
// defaults: prose-typed factories (text, longText, editor, slug)
81+
// default to searchable:true; everything else defaults to false.
82+
// Callers always override with `->searchable(true|false)`.
6783
// ---------------------------------------------------------------------
6884

6985
public static function text(int $categoryId, string $name, ?string $label = null): self
7086
{
71-
return new self(null, $categoryId, $name, $label, FieldType::Text);
87+
return new self(null, $categoryId, $name, $label, FieldType::Text, searchable: true);
7288
}
7389

7490
public static function longText(int $categoryId, string $name, ?string $label = null): self
7591
{
76-
return new self(null, $categoryId, $name, $label, FieldType::LongText);
92+
return new self(null, $categoryId, $name, $label, FieldType::LongText, searchable: true);
7793
}
7894

7995
public static function editor(int $categoryId, string $name, ?string $label = null): self
8096
{
81-
return new self(null, $categoryId, $name, $label, FieldType::Editor);
97+
return new self(null, $categoryId, $name, $label, FieldType::Editor, searchable: true);
8298
}
8399

84100
public static function slug(int $categoryId, string $name, ?string $label = null): self
85101
{
86-
return new self(null, $categoryId, $name, $label, FieldType::Slug);
102+
return new self(null, $categoryId, $name, $label, FieldType::Slug, searchable: true);
87103
}
88104

89105
public static function password(int $categoryId, string $name, ?string $label = null): self
90106
{
91-
return new self(null, $categoryId, $name, $label, FieldType::Password);
107+
return new self(null, $categoryId, $name, $label, FieldType::Password, searchable: false);
92108
}
93109

94110
public static function integer(int $categoryId, string $name, ?string $label = null): self
95111
{
96-
return new self(null, $categoryId, $name, $label, FieldType::Integer);
112+
return new self(null, $categoryId, $name, $label, FieldType::Integer, searchable: false);
97113
}
98114

99115
public static function decimal(int $categoryId, string $name, ?string $label = null): self
100116
{
101-
return new self(null, $categoryId, $name, $label, FieldType::Decimal);
117+
return new self(null, $categoryId, $name, $label, FieldType::Decimal, searchable: false);
102118
}
103119

104120
public static function money(int $categoryId, string $name, ?string $label = null): self
105121
{
106-
return new self(null, $categoryId, $name, $label, FieldType::Money);
122+
return new self(null, $categoryId, $name, $label, FieldType::Money, searchable: false);
107123
}
108124

109125
public static function checkbox(int $categoryId, string $name, ?string $label = null): self
110126
{
111-
return new self(null, $categoryId, $name, $label, FieldType::Checkbox);
127+
return new self(null, $categoryId, $name, $label, FieldType::Checkbox, searchable: false);
112128
}
113129

114130
public static function dropdown(int $categoryId, string $name, ?string $label = null): self
115131
{
116-
return new self(null, $categoryId, $name, $label, FieldType::Dropdown);
132+
return new self(null, $categoryId, $name, $label, FieldType::Dropdown, searchable: false);
117133
}
118134

119135
public static function datepicker(int $categoryId, string $name, ?string $label = null): self
120136
{
121-
return new self(null, $categoryId, $name, $label, FieldType::Datepicker);
137+
return new self(null, $categoryId, $name, $label, FieldType::Datepicker, searchable: false);
122138
}
123139

124140
public static function hidden(int $categoryId, string $name, ?string $label = null): self
125141
{
126-
return new self(null, $categoryId, $name, $label, FieldType::Hidden);
142+
return new self(null, $categoryId, $name, $label, FieldType::Hidden, searchable: false);
127143
}
128144

129145
public static function arrayList(int $categoryId, string $name, ?string $label = null): self
130146
{
131-
return new self(null, $categoryId, $name, $label, FieldType::ArrayList);
147+
return new self(null, $categoryId, $name, $label, FieldType::ArrayList, searchable: false);
132148
}
133149

134150
public static function file(int $categoryId, string $name, ?string $label = null): self
135151
{
136-
return new self(null, $categoryId, $name, $label, FieldType::Fileupload);
152+
return new self(null, $categoryId, $name, $label, FieldType::Fileupload, searchable: false);
137153
}
138154

139155
public static function image(int $categoryId, string $name, ?string $label = null): self
140156
{
141-
return new self(null, $categoryId, $name, $label, FieldType::Imageupload);
157+
return new self(null, $categoryId, $name, $label, FieldType::Imageupload, searchable: false);
142158
}
143159

144160
public static function filePicker(int $categoryId, string $name, ?string $label = null): self
145161
{
146-
return new self(null, $categoryId, $name, $label, FieldType::Filepicker);
162+
return new self(null, $categoryId, $name, $label, FieldType::Filepicker, searchable: false);
147163
}
148164

149165
// ---------------------------------------------------------------------

tests/Unit/Domain/FieldTest.php

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -101,21 +101,23 @@ public function testRejectsNegativePosition(): void
101101
#[DataProvider('factories')]
102102
public function testFactoryReturnsFreshFieldOfExpectedType(
103103
callable $factory,
104-
FieldType $expected,
104+
FieldType $expectedType,
105+
bool $expectedSearchable,
105106
): void {
106107
$f = $factory(7, 'col', 'Column');
107108

108109
self::assertNull($f->id);
109110
self::assertSame(7, $f->categoryId);
110111
self::assertSame('col', $f->name);
111112
self::assertSame('Column', $f->label);
112-
self::assertSame($expected, $f->type);
113+
self::assertSame($expectedType, $f->type);
113114

114-
// Defaults must be untouched by the factory.
115+
// required + indexed always default false from the factory; the
116+
// per-type `searchable` default is asserted via the provider.
115117
self::assertSame(0, $f->position);
116118
self::assertFalse($f->required);
117119
self::assertFalse($f->indexed);
118-
self::assertFalse($f->searchable);
120+
self::assertSame($expectedSearchable, $f->searchable);
119121
self::assertSame([], $f->config);
120122
self::assertSame(0, $f->created);
121123
self::assertSame(0, $f->updated);
@@ -128,26 +130,30 @@ public function testFactoryLabelDefaultsToNull(): void
128130
}
129131

130132
/**
131-
* @return iterable<string, array{0: callable, 1: FieldType}>
133+
* Each factory returns its per-type `searchable` default — prose-typed
134+
* fields (text, longText, editor, slug) opt INTO the FTS body; every
135+
* other type opts OUT. Callers always override via `->searchable()`.
136+
*
137+
* @return iterable<string, array{0: callable, 1: FieldType, 2: bool}>
132138
*/
133139
public static function factories(): iterable
134140
{
135-
yield 'text' => [Field::text(...), FieldType::Text];
136-
yield 'longText' => [Field::longText(...), FieldType::LongText];
137-
yield 'editor' => [Field::editor(...), FieldType::Editor];
138-
yield 'slug' => [Field::slug(...), FieldType::Slug];
139-
yield 'password' => [Field::password(...), FieldType::Password];
140-
yield 'integer' => [Field::integer(...), FieldType::Integer];
141-
yield 'decimal' => [Field::decimal(...), FieldType::Decimal];
142-
yield 'money' => [Field::money(...), FieldType::Money];
143-
yield 'checkbox' => [Field::checkbox(...), FieldType::Checkbox];
144-
yield 'dropdown' => [Field::dropdown(...), FieldType::Dropdown];
145-
yield 'datepicker' => [Field::datepicker(...), FieldType::Datepicker];
146-
yield 'hidden' => [Field::hidden(...), FieldType::Hidden];
147-
yield 'arrayList' => [Field::arrayList(...), FieldType::ArrayList];
148-
yield 'file' => [Field::file(...), FieldType::Fileupload];
149-
yield 'image' => [Field::image(...), FieldType::Imageupload];
150-
yield 'filePicker' => [Field::filePicker(...), FieldType::Filepicker];
141+
yield 'text' => [Field::text(...), FieldType::Text, true];
142+
yield 'longText' => [Field::longText(...), FieldType::LongText, true];
143+
yield 'editor' => [Field::editor(...), FieldType::Editor, true];
144+
yield 'slug' => [Field::slug(...), FieldType::Slug, true];
145+
yield 'password' => [Field::password(...), FieldType::Password, false];
146+
yield 'integer' => [Field::integer(...), FieldType::Integer, false];
147+
yield 'decimal' => [Field::decimal(...), FieldType::Decimal, false];
148+
yield 'money' => [Field::money(...), FieldType::Money, false];
149+
yield 'checkbox' => [Field::checkbox(...), FieldType::Checkbox, false];
150+
yield 'dropdown' => [Field::dropdown(...), FieldType::Dropdown, false];
151+
yield 'datepicker' => [Field::datepicker(...), FieldType::Datepicker, false];
152+
yield 'hidden' => [Field::hidden(...), FieldType::Hidden, false];
153+
yield 'arrayList' => [Field::arrayList(...), FieldType::ArrayList, false];
154+
yield 'file' => [Field::file(...), FieldType::Fileupload, false];
155+
yield 'image' => [Field::image(...), FieldType::Imageupload, false];
156+
yield 'filePicker' => [Field::filePicker(...), FieldType::Filepicker, false];
151157
}
152158

153159
// -----------------------------------------------------------------
@@ -164,8 +170,8 @@ public function testRequiredFlagFlips(): void
164170
self::assertTrue($on->required);
165171
self::assertFalse($off->required);
166172
// The other flags carry over untouched.
167-
self::assertFalse($on->indexed);
168-
self::assertFalse($on->searchable);
173+
self::assertSame($f->indexed, $on->indexed);
174+
self::assertSame($f->searchable, $on->searchable);
169175
}
170176

171177
public function testIndexedFlagFlips(): void
@@ -175,11 +181,19 @@ public function testIndexedFlagFlips(): void
175181
self::assertFalse($f->indexed(false)->indexed);
176182
}
177183

178-
public function testSearchableFlagFlips(): void
184+
public function testSearchableFlagFlipsInBothDirections(): void
179185
{
180-
$f = Field::text(1, 'x')->searchable();
181-
self::assertTrue($f->searchable);
182-
self::assertFalse($f->searchable(false)->searchable);
186+
// Field::password() defaults to searchable:false — opt IN, then back OUT.
187+
$off = Field::password(1, 'pw');
188+
self::assertFalse($off->searchable);
189+
self::assertTrue($off->searchable()->searchable);
190+
self::assertFalse($off->searchable()->searchable(false)->searchable);
191+
192+
// Field::text() defaults to searchable:true — opt OUT, then back IN.
193+
$on = Field::text(1, 'title');
194+
self::assertTrue($on->searchable);
195+
self::assertFalse($on->searchable(false)->searchable);
196+
self::assertTrue($on->searchable(false)->searchable()->searchable);
183197
}
184198

185199
public function testPositionSetterReplacesValue(): void
@@ -282,7 +296,10 @@ public function testTypeAwareSetterOverwritesItsOwnKeyOnly(): void
282296

283297
public function testChainedSettersComposeAndReturnNewInstances(): void
284298
{
285-
$original = Field::text(1, 'title');
299+
// Use a bare constructor so all flags start false — keeps the
300+
// "every setter flips its flag" demonstration independent of
301+
// per-factory smart defaults.
302+
$original = new Field(null, 1, 'title', null, FieldType::Text);
286303
$built = $original
287304
->required()
288305
->indexed()

tests/Unit/Storage/FieldRepositoryContract.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,20 +208,23 @@ public function testEnsureReturnsExistingWhenNameIsPresent(): void
208208

209209
public function testEnsureDoesNotUpdateFlagsOnHit(): void
210210
{
211+
// First insert uses Field::password — flags all default false,
212+
// so the "ensure() must not flip flags on hit" invariant reads
213+
// cleanly without fighting per-factory searchable defaults.
211214
$original = $this->storage->fields()->ensure(
212-
Field::text($this->categoryId, 'title', 'Title'),
215+
Field::password($this->categoryId, 'secret', 'Secret'),
213216
);
214217

215218
// Caller hands in a different label + flags; ensure() must NOT
216219
// apply them — switching indexed/searchable silently would be a
217220
// structural surprise.
218221
$second = $this->storage->fields()->ensure(
219-
Field::text($this->categoryId, 'title', 'Different Label')
222+
Field::password($this->categoryId, 'secret', 'Different Label')
220223
->required()->indexed()->searchable()->maxLength(500),
221224
);
222225

223226
self::assertSame($original->id, $second->id);
224-
self::assertSame('Title', $second->label); // unchanged
227+
self::assertSame('Secret', $second->label); // unchanged
225228
self::assertFalse($second->required); // unchanged
226229
self::assertFalse($second->indexed); // unchanged
227230
self::assertFalse($second->searchable); // unchanged

0 commit comments

Comments
 (0)