Skip to content

Commit 6580267

Browse files
Add v6 visibility filter contract with contains operators for instance/run/workflow/business key fields.
Add contains visibility filters
1 parent dcd5427 commit 6580267

3 files changed

Lines changed: 182 additions & 13 deletions

File tree

src/V2/Support/VisibilityFilters.php

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
final class VisibilityFilters
1313
{
14-
public const VERSION = 5;
14+
public const VERSION = 6;
1515

1616
public const MINIMUM_SUPPORTED_VERSION = 1;
1717

@@ -22,6 +22,10 @@ final class VisibilityFilters
2222
'namespace' => 'Namespace',
2323
'workflow_type' => 'Workflow Type',
2424
'business_key' => 'Business Key',
25+
'instance_id_contains' => 'Instance ID Contains',
26+
'run_id_contains' => 'Run ID Contains',
27+
'workflow_type_contains' => 'Workflow Type Contains',
28+
'business_key_contains' => 'Business Key Contains',
2529
'compatibility' => 'Compatibility',
2630
'declared_entry_mode' => 'Entry Contract',
2731
'declared_contract_source' => 'Command Contract Source',
@@ -68,6 +72,13 @@ final class VisibilityFilters
6872
'is_terminal',
6973
];
7074

75+
private const CONTAINS_FIELDS = [
76+
'instance_id_contains' => 'instance_id',
77+
'run_id_contains' => 'run_id',
78+
'workflow_type_contains' => 'workflow_type',
79+
'business_key_contains' => 'business_key',
80+
];
81+
7182
private const LABEL_KEY_REGEX = '^[A-Za-z0-9_.:-]{1,64}$';
7283

7384
private const LABEL_KEY_PATTERN = '/^[A-Za-z0-9_.:-]{1,64}$/';
@@ -81,7 +92,7 @@ final class VisibilityFilters
8192
*/
8293
public static function supportedVersions(): array
8394
{
84-
return [1, 2, 3, 4, self::VERSION];
95+
return [1, 2, 3, 4, 5, self::VERSION];
8596
}
8697

8798
public static function minimumSupportedVersion(): int
@@ -141,7 +152,7 @@ public static function mixedFleetPolicy(): array
141152
'Summaries projected by older workers may have NULL for fields added in later schema versions; exact-match filters will not match NULL, so those rows are excluded from filtered views until re-projected.',
142153
'The rebuild-projections command re-projects from durable runtime state, filling in any derived fields missing from older schema versions.',
143154
'Saved views remain readable across filter version bumps; updating a deprecated saved view rewrites it onto the current version.',
144-
'Boolean and string filter fields use exact-match semantics; no partial or range matching is applied.',
155+
'Boolean and base string filter fields use exact-match semantics; explicit *_contains string filters use escaped SQL LIKE substring matching on the run-summary projection.',
145156
],
146157
'projection_backfill_authority' => [
147158
'trigger' => 'Summaries with a NULL or lower projection_schema_version than the current build need re-projection.',
@@ -161,6 +172,14 @@ public static function exactFields(): array
161172
return [...self::STRING_FIELDS, ...self::BOOLEAN_FIELDS];
162173
}
163174

175+
/**
176+
* @return list<string>
177+
*/
178+
public static function containsFields(): array
179+
{
180+
return array_keys(self::CONTAINS_FIELDS);
181+
}
182+
164183
/**
165184
* @return array<string, mixed>
166185
*/
@@ -177,6 +196,10 @@ public static function definition(): array
177196
$fields[$field] = self::fieldDefinition($field, 'boolean', $order++);
178197
}
179198

199+
foreach (self::CONTAINS_FIELDS as $field => $exactField) {
200+
$fields[$field] = self::fieldDefinition($field, 'string', $order++, 'contains', $exactField);
201+
}
202+
180203
return [
181204
'version' => self::VERSION,
182205
'minimum_supported_version' => self::MINIMUM_SUPPORTED_VERSION,
@@ -285,6 +308,18 @@ public static function normalize(array $filters): array
285308
}
286309
}
287310

311+
foreach (self::CONTAINS_FIELDS as $field => $exactField) {
312+
$value = self::stringValue($filters[$field] ?? null);
313+
314+
if ($value === null && is_array($filters['contains'] ?? null)) {
315+
$value = self::stringValue($filters['contains'][$exactField] ?? null);
316+
}
317+
318+
if ($value !== null) {
319+
$normalized[$field] = $value;
320+
}
321+
}
322+
288323
$labels = self::normalizeLabels($filters['labels'] ?? $filters['label'] ?? []);
289324

290325
if ($labels !== []) {
@@ -311,6 +346,10 @@ public static function fromRequest(Request $request): array
311346
$filters[$field] = $request->query($field);
312347
}
313348

349+
foreach (self::containsFields() as $field) {
350+
$filters[$field] = $request->query($field);
351+
}
352+
314353
$filters['labels'] = $request->query('label', $request->query('labels', []));
315354
$filters['search_attributes'] = $request->query('search_attribute', $request->query('search_attributes', []));
316355

@@ -334,6 +373,12 @@ public static function merge(array ...$filters): array
334373
}
335374
}
336375

376+
foreach (self::containsFields() as $field) {
377+
if (array_key_exists($field, $normalized)) {
378+
$merged[$field] = $normalized[$field];
379+
}
380+
}
381+
337382
if (isset($normalized['labels']) && is_array($normalized['labels'])) {
338383
$merged['labels'] = [...($merged['labels'] ?? []), ...$normalized['labels']];
339384
}
@@ -372,6 +417,12 @@ public static function apply(Builder $query, array $filters): Builder
372417
}
373418
}
374419

420+
foreach (self::CONTAINS_FIELDS as $field => $exactField) {
421+
if (array_key_exists($field, $normalized)) {
422+
self::applyContainsFilter($query, self::columnForField($exactField), $normalized[$field]);
423+
}
424+
}
425+
375426
foreach (self::BOOLEAN_FIELDS as $field) {
376427
if (in_array($field, ['archived', 'is_terminal'], true) || ! array_key_exists($field, $normalized)) {
377428
continue;
@@ -537,8 +588,13 @@ private static function normalizeLabels(mixed $labels): array
537588
/**
538589
* @return array<string, mixed>
539590
*/
540-
private static function fieldDefinition(string $field, string $type, int $order): array
541-
{
591+
private static function fieldDefinition(
592+
string $field,
593+
string $type,
594+
int $order,
595+
string $operator = 'exact',
596+
?string $containsField = null,
597+
): array {
542598
$options = self::optionsForField($field, $type);
543599

544600
$definition = [
@@ -547,13 +603,17 @@ private static function fieldDefinition(string $field, string $type, int $order)
547603
'input' => $type === 'boolean'
548604
? 'boolean_select'
549605
: ($options === [] ? 'text' : 'select'),
550-
'operator' => 'exact',
606+
'operator' => $operator,
551607
'filterable' => true,
552608
'saved_view_compatible' => true,
553609
'order' => $order,
554610
'query_parameter' => $field,
555611
];
556612

613+
if ($containsField !== null) {
614+
$definition['contains_field'] = $containsField;
615+
}
616+
557617
$help = self::helpForField($field);
558618

559619
if ($help !== null) {
@@ -627,10 +687,27 @@ private static function helpForField(string $field): ?string
627687
{
628688
return match ($field) {
629689
'business_key' => 'Exact-match indexed operator metadata copied onto the run summary and saved-view contract.',
690+
'instance_id_contains' => 'Substring match against workflow instance IDs for fragment lookup.',
691+
'run_id_contains' => 'Substring match against workflow run IDs for fragment lookup.',
692+
'workflow_type_contains' => 'Substring match against workflow type names for fragment lookup.',
693+
'business_key_contains' => 'Substring match against indexed business keys for fragment lookup.',
630694
default => null,
631695
};
632696
}
633697

698+
/**
699+
* @param Builder<\Illuminate\Database\Eloquent\Model> $query
700+
*/
701+
private static function applyContainsFilter(Builder $query, string $column, string $value): void
702+
{
703+
$wrappedColumn = $query->getQuery()
704+
->getGrammar()
705+
->wrap($column);
706+
$escaped = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value);
707+
708+
$query->whereRaw($wrappedColumn . " LIKE ? ESCAPE '!'", ['%' . $escaped . '%']);
709+
}
710+
634711
/**
635712
* @return array<int, array{label: string, value: string|bool}>
636713
*/

tests/Feature/V2/V2SearchAttributeTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ public function testVisibilityFiltersDefinitionIncludesSearchAttributes(): void
174174

175175
public function testVisibilityFilterVersionIsUpdated(): void
176176
{
177-
$this->assertSame(5, VisibilityFilters::VERSION);
177+
$this->assertSame(6, VisibilityFilters::VERSION);
178+
$this->assertContains(6, VisibilityFilters::supportedVersions());
178179
$this->assertContains(5, VisibilityFilters::supportedVersions());
179180
$this->assertContains(3, VisibilityFilters::supportedVersions());
180181
}

tests/Unit/V2/VisibilityFiltersTest.php

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ public function testNormalizeKeepsOnlyVersionedVisibilityFields(): void
2222
'is_current_run' => 'yes',
2323
'namespace' => ' production ',
2424
'workflow_type' => ' billing.invoice-sync ',
25+
'workflow_type_contains' => ' invoice ',
2526
'business_key' => '',
27+
'contains' => [
28+
'instance_id' => ' visibility ',
29+
'business_key' => ' order ',
30+
],
2631
'compatibility' => 'build-a',
2732
'declared_entry_mode' => ' canonical ',
2833
'declared_contract_source' => ' durable_history ',
@@ -62,6 +67,9 @@ public function testNormalizeKeepsOnlyVersionedVisibilityFields(): void
6267
'task_problem' => true,
6368
'archived' => true,
6469
'is_terminal' => false,
70+
'instance_id_contains' => 'visibility',
71+
'workflow_type_contains' => 'invoice',
72+
'business_key_contains' => 'order',
6573
'labels' => [
6674
'region' => 'us-east',
6775
'tenant' => 'acme',
@@ -91,6 +99,7 @@ public function testMergeLetsLaterFiltersRefineSavedViewLabels(): void
9199
'labels' => [
92100
'region' => 'eu-west',
93101
],
102+
'workflow_type_contains' => 'invoice',
94103
],
95104
);
96105

@@ -101,6 +110,7 @@ public function testMergeLetsLaterFiltersRefineSavedViewLabels(): void
101110
'repair_attention' => true,
102111
'continue_as_new_recommended' => true,
103112
'archived' => true,
113+
'workflow_type_contains' => 'invoice',
104114
'labels' => [
105115
'region' => 'eu-west',
106116
'tenant' => 'acme',
@@ -234,19 +244,100 @@ public function testApplyFiltersUseBooleanExactFields(): void
234244
$this->assertSame(['01JVISBOOLMATCH00000000001'], $ids);
235245
}
236246

247+
public function testApplyFiltersRunSummariesByContainsFields(): void
248+
{
249+
WorkflowRunSummary::create([
250+
'id' => '01JVISC0NTAINMATCH0000001',
251+
'workflow_instance_id' => 'tenant-acme-order-12345',
252+
'run_number' => 1,
253+
'is_current_run' => true,
254+
'engine_source' => 'v2',
255+
'class' => 'BillingWorkflow',
256+
'workflow_type' => 'billing.invoice-sync',
257+
'business_key' => 'order_12345',
258+
'status' => 'waiting',
259+
'status_bucket' => 'running',
260+
]);
261+
WorkflowRunSummary::create([
262+
'id' => '01JVISC0NTAINMISS00000001',
263+
'workflow_instance_id' => 'tenant-beta-invoice-999',
264+
'run_number' => 1,
265+
'is_current_run' => true,
266+
'engine_source' => 'v2',
267+
'class' => 'BillingWorkflow',
268+
'workflow_type' => 'billing.invoice-sync',
269+
'business_key' => 'order-12345',
270+
'status' => 'waiting',
271+
'status_bucket' => 'running',
272+
]);
273+
274+
$ids = VisibilityFilters::apply(WorkflowRunSummary::query(), [
275+
'instance_id_contains' => 'acme-order',
276+
'run_id_contains' => 'MATCH',
277+
'workflow_type_contains' => 'invoice',
278+
'business_key_contains' => 'order_',
279+
])->pluck('id')
280+
->all();
281+
282+
$this->assertSame(['01JVISC0NTAINMATCH0000001'], $ids);
283+
}
284+
285+
public function testContainsFiltersEscapeSqlWildcards(): void
286+
{
287+
WorkflowRunSummary::create([
288+
'id' => '01JVISC0NTAINESCAPEMATCH01',
289+
'workflow_instance_id' => 'tenant-order-A_01',
290+
'run_number' => 1,
291+
'is_current_run' => true,
292+
'engine_source' => 'v2',
293+
'class' => 'BillingWorkflow',
294+
'workflow_type' => 'billing.invoice-sync',
295+
'business_key' => 'order-123',
296+
'status' => 'waiting',
297+
'status_bucket' => 'running',
298+
]);
299+
WorkflowRunSummary::create([
300+
'id' => '01JVISC0NTAINESCAPEMISS01',
301+
'workflow_instance_id' => 'tenant-order-AB01',
302+
'run_number' => 1,
303+
'is_current_run' => true,
304+
'engine_source' => 'v2',
305+
'class' => 'BillingWorkflow',
306+
'workflow_type' => 'billing.invoice-sync',
307+
'business_key' => 'order-456',
308+
'status' => 'waiting',
309+
'status_bucket' => 'running',
310+
]);
311+
312+
$ids = VisibilityFilters::apply(WorkflowRunSummary::query(), [
313+
'instance_id_contains' => 'A_01',
314+
])->pluck('id')
315+
->all();
316+
317+
$this->assertSame(['01JVISC0NTAINESCAPEMATCH01'], $ids);
318+
}
319+
237320
public function testDefinitionDescribesExactVisibilityContract(): void
238321
{
239322
$definition = VisibilityFilters::definition();
240323

241324
$this->assertSame(VisibilityFilters::VERSION, $definition['version']);
242-
$this->assertSame([1, 2, 3, 4, VisibilityFilters::VERSION], $definition['supported_versions']);
325+
$this->assertSame([1, 2, 3, 4, 5, VisibilityFilters::VERSION], $definition['supported_versions']);
243326
$this->assertSame('Instance ID', $definition['fields']['instance_id']['label']);
244327
$this->assertSame('string', $definition['fields']['instance_id']['type']);
245328
$this->assertSame('text', $definition['fields']['instance_id']['input']);
246329
$this->assertTrue($definition['fields']['instance_id']['filterable']);
247330
$this->assertTrue($definition['fields']['instance_id']['saved_view_compatible']);
248331
$this->assertSame(0, $definition['fields']['instance_id']['order']);
249332
$this->assertSame('string', $definition['fields']['run_id']['type']);
333+
$this->assertSame('contains', $definition['fields']['instance_id_contains']['operator']);
334+
$this->assertSame('instance_id', $definition['fields']['instance_id_contains']['contains_field']);
335+
$this->assertSame('instance_id_contains', $definition['fields']['instance_id_contains']['query_parameter']);
336+
$this->assertTrue($definition['fields']['workflow_type_contains']['saved_view_compatible']);
337+
$this->assertSame(
338+
'Substring match against workflow type names for fragment lookup.',
339+
$definition['fields']['workflow_type_contains']['help'],
340+
);
250341
$this->assertSame('Namespace', $definition['fields']['namespace']['label']);
251342
$this->assertSame('string', $definition['fields']['namespace']['type']);
252343
$this->assertSame('text', $definition['fields']['namespace']['input']);
@@ -403,7 +494,7 @@ public function testVersionMetadataMarksUnsupportedSavedViewContractsExplicitly(
403494
$this->assertSame(VisibilityFilters::VERSION, $supported['version']);
404495
$this->assertSame(VisibilityFilters::VERSION, $supported['current_version']);
405496
$this->assertSame(VisibilityFilters::MINIMUM_SUPPORTED_VERSION, $supported['minimum_supported_version']);
406-
$this->assertSame([1, 2, 3, 4, VisibilityFilters::VERSION], $supported['supported_versions']);
497+
$this->assertSame([1, 2, 3, 4, 5, VisibilityFilters::VERSION], $supported['supported_versions']);
407498
$this->assertTrue($supported['supported']);
408499
$this->assertFalse($supported['deprecated']);
409500
$this->assertSame('supported', $supported['status']);
@@ -412,12 +503,12 @@ public function testVersionMetadataMarksUnsupportedSavedViewContractsExplicitly(
412503
$this->assertSame(99, $unsupported['version']);
413504
$this->assertSame(VisibilityFilters::VERSION, $unsupported['current_version']);
414505
$this->assertSame(VisibilityFilters::MINIMUM_SUPPORTED_VERSION, $unsupported['minimum_supported_version']);
415-
$this->assertSame([1, 2, 3, 4, VisibilityFilters::VERSION], $unsupported['supported_versions']);
506+
$this->assertSame([1, 2, 3, 4, 5, VisibilityFilters::VERSION], $unsupported['supported_versions']);
416507
$this->assertFalse($unsupported['supported']);
417508
$this->assertFalse($unsupported['deprecated']);
418509
$this->assertSame('unsupported', $unsupported['status']);
419510
$this->assertSame(
420-
'This saved view uses visibility filter version 99, but this Waterline build supports version 1, 2, 3, 4, 5.',
511+
'This saved view uses visibility filter version 99, but this Waterline build supports version 1, 2, 3, 4, 5, 6.',
421512
$unsupported['message'],
422513
);
423514
}
@@ -432,7 +523,7 @@ public function testVersionMetadataMarksDeprecatedVersionsExplicitly(): void
432523
$this->assertTrue($deprecated['deprecated']);
433524
$this->assertSame('deprecated', $deprecated['status']);
434525
$this->assertSame(
435-
'This saved view uses deprecated visibility filter version 1. Consider updating it to the current version 5.',
526+
'This saved view uses deprecated visibility filter version 1. Consider updating it to the current version 6.',
436527
$deprecated['message'],
437528
);
438529

@@ -454,7 +545,7 @@ public function testVersionEvolutionPolicyExposesStableContract(): void
454545

455546
$this->assertSame(VisibilityFilters::VERSION, $policy['current_version']);
456547
$this->assertSame(VisibilityFilters::MINIMUM_SUPPORTED_VERSION, $policy['minimum_supported_version']);
457-
$this->assertSame([1, 2, 3, 4, VisibilityFilters::VERSION], $policy['supported_versions']);
548+
$this->assertSame([1, 2, 3, 4, 5, VisibilityFilters::VERSION], $policy['supported_versions']);
458549
$this->assertSame([1, 2], $policy['deprecated_versions']);
459550
$this->assertSame('system:', $policy['reserved_view_id_prefix']);
460551
$this->assertIsString($policy['upgrade_policy']);

0 commit comments

Comments
 (0)