diff --git a/docs/api-stability.md b/docs/api-stability.md index c05365cbb..af9bc789e 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -72,6 +72,35 @@ Waterline projections, and the explicit v2.0 support boundaries. This stability document freezes the public API and event payload rules those mapped features rely on. +## Schedule system-field visibility queries + +`Workflow\V2\Support\ScheduleVisibilityQuery` supplies the schedule-only +equality/AND parser and a fresh namespace-bound Eloquent query. `parse($query)` +returns typed field/column/literal predicates. `query($namespace, $query)` +applies them to non-deleted schedules before callers count, order or paginate. +The namespace is a separate required argument and cannot be selected through +query text. Literal values remain SQL bindings. The planner resolves +`workflows.v2.schedule_model` and uses Core’s exact namespace identity predicate +on its configured table and connection, including case/space-folding databases. + +The supported fields are `ScheduleId`, `Status`, `WorkflowType`, `TaskQueue` +and `Note`. Use quoted strings; status is `active` or `paused`. Single-quoted +strings support escaped quotes/backslashes; double-quoted strings use JSON +escaping. Whitespace-delimited `AND` is case-insensitive outside strings. +Queries are limited to 2048 UTF-8 characters after native-compatible request +edge normalization (Unicode whitespace, BOM, zero-width space and left-to-right +mark). Characters inside quoted literals are preserved. Unsupported fields/operators, +invalid types, malformed strings and unavailable attribute queries throw +`ScheduleVisibilityQueryException` with a typed `reason`. They never become +unfiltered or fabricated empty results. + +This helper does not implement workflow-run DQL, registered attribute queries, +list counters or a continuation-token protocol. Embedded callers retain their +existing pagination; a Server schedule cursor has its own namespace/filter +identity contract. Portable system-field vectors live under +`tests/Fixtures/ScheduleVisibilityQuery/`. Consumers must check that the helper +is present in their actual installed package before enabling query controls. + ## Scope The workflow package is consumed by three distinct audiences: diff --git a/src/V2/Support/ScheduleVisibilityQuery.php b/src/V2/Support/ScheduleVisibilityQuery.php new file mode 100644 index 000000000..6ce43e8d6 --- /dev/null +++ b/src/V2/Support/ScheduleVisibilityQuery.php @@ -0,0 +1,362 @@ + + */ + private const SYSTEM_FIELDS = [ + 'ScheduleId' => [ + 'column' => 'schedule_id', + 'type' => 'keyword', + ], + 'Status' => [ + 'column' => 'status', + 'type' => 'schedule_status', + ], + 'WorkflowType' => [ + 'column' => 'action->workflow_type', + 'type' => 'keyword', + ], + 'TaskQueue' => [ + 'column' => 'action->task_queue', + 'type' => 'keyword', + ], + 'Note' => [ + 'column' => 'note', + 'type' => 'text', + ], + ]; + + /** + * Parse the documented schedule visibility grammar. + * + * The grammar deliberately supports only equality predicates joined by + * AND. Keeping the accepted surface small makes unsupported predicates a + * typed refusal instead of an expression that is silently ignored. + * + * @return list + */ + public function parse(string $query): array + { + // Match the native request normalization before its 1..2048 rule: + // Unicode White_Space plus Laravel's BOM/zero-width edge characters. + // Quoted literal contents are untouched. + $edge = '\x{0009}-\x{000D}\x{0020}\x{0085}\x{00A0}\x{1680}' + . '\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}' + . '\x{FEFF}\x{200B}\x{200E}'; + $normalized = preg_replace('/\A[' . $edge . ']+|[' . $edge . ']+\z/u', '', $query); + if ($normalized === null) { + throw new ScheduleVisibilityQueryException('Schedule visibility query must be valid UTF-8.'); + } + $length = preg_match_all('/./us', $normalized); + if ($length === false || $length > 2048) { + throw new ScheduleVisibilityQueryException( + 'Schedule visibility query must contain at most 2048 UTF-8 characters.' + ); + } + + $query = trim($normalized); + + if ($query === '') { + throw new ScheduleVisibilityQueryException('Schedule visibility query must not be empty.'); + } + + $parts = $this->splitAndPredicates($query); + + if ($parts === []) { + throw new ScheduleVisibilityQueryException( + 'Schedule visibility query contains an unterminated string or empty predicate.', + ); + } + + $predicates = []; + + foreach ($parts as $part) { + $this->rejectUnsupportedPredicate($part); + + if (preg_match('/^([A-Za-z]\w*)\s*=\s*(.+)$/s', $part, $matches) !== 1) { + throw new ScheduleVisibilityQueryException( + 'Schedule visibility predicates must use: Field = literal.', + ); + } + + $field = $matches[1]; + if (preg_match('/[^\x00-\x7F]/', $field) === 1) { + throw new ScheduleVisibilityQueryException( + 'Schedule visibility field names must be ASCII identifiers.' + ); + } + $literal = $this->parseLiteral(trim($matches[2])); + $system = self::SYSTEM_FIELDS[$field] ?? null; + + if ($system !== null) { + $type = $system['type']; + $column = $system['column']; + } else { + throw new ScheduleVisibilityQueryException( + sprintf( + 'Schedule visibility field [%s] is not searchable. Use ScheduleId, Status, ' + . 'WorkflowType, TaskQueue, or Note.', + $field, + ), + 'unsupported_schedule_visibility_field', + ); + } + + $this->validateLiteralType($field, $type, $literal); + + $predicates[] = [ + 'field' => $field, + 'column' => $column, + 'type' => $type, + 'literal' => $literal, + ]; + } + + return $predicates; + } + + /** + * Build a fresh namespace-bound schedule query. Call counts/order/pagination + * on the returned builder; never filter an already selected page. + * + * @return Builder + */ + public function query(string $namespace, string $query): Builder + { + if (trim($namespace) === '') { + throw new ScheduleVisibilityQueryException('Schedule visibility requires an explicit namespace.'); + } + + $predicates = $this->parse($query); + $model = ConfiguredV2Models::resolve('schedule_model', WorkflowSchedule::class); + $builder = NamespaceIdentity::constrain($model::query(), $namespace) + ->where('status', '!=', 'deleted'); + + foreach ($predicates as $predicate) { + $builder->where($predicate['column'], '=', $predicate['literal']); + } + + return $builder; + } + + private function rejectUnsupportedPredicate(string $predicate): void + { + $unquoted = $this->withoutQuotedLiterals($predicate); + + if (preg_match('/(?:!=|<>|>=|<=|>|<)/', $unquoted) === 1 + || preg_match('/\b(?:OR|NOT|IN|LIKE|CONTAINS|STARTS_WITH|ENDS_WITH)\b/i', $unquoted) === 1 + ) { + throw new ScheduleVisibilityQueryException( + 'Schedule visibility supports only equality predicates joined by AND.', + 'unsupported_schedule_visibility_predicate', + ); + } + } + + private function withoutQuotedLiterals(string $value): string + { + $result = ''; + $quote = null; + $index = 0; + $length = strlen($value); + + while ($index < $length) { + $character = $value[$index]; + if ($quote !== null) { + [$index, $quote] = $this->quotedSegment($value, $index, $quote); + $result .= ' '; + continue; + } + $index++; + if ($character === '"' || $character === "'") { + $quote = $character; + $result .= ' '; + } else { + $result .= $character; + } + } + + return $result; + } + + /** + * @return array{int, string|null, string} + */ + private function quotedSegment(string $value, int $index, string $quote): array + { + $character = $value[$index]; + if ($character === '\\' && isset($value[$index + 1])) { + return [$index + 2, $quote, $character . $value[$index + 1]]; + } + + return [$index + 1, $character === $quote ? null : $quote, $character]; + } + + /** + * @return list + */ + private function splitAndPredicates(string $query): array + { + $parts = []; + $current = ''; + $quote = null; + $length = strlen($query); + $index = 0; + + while ($index < $length) { + $character = $query[$index]; + if ($quote !== null) { + [$index, $quote, $segment] = $this->quotedSegment($query, $index, $quote); + $current .= $segment; + continue; + } + if ($character === '"' || $character === "'") { + $quote = $character; + $current .= $character; + $index++; + continue; + } + if ($this->startsAndAt($query, $index)) { + $part = trim($current); + if ($part === '') { + return []; + } + $parts[] = $part; + $current = ''; + $index += 3; + } else { + $current .= $character; + $index++; + } + } + $last = trim($current); + if ($quote !== null || $last === '') { + return []; + } + $parts[] = $last; + + return $parts; + } + + private function startsAndAt(string $query, int $index): bool + { + if (strncasecmp(substr($query, $index, 3), 'AND', 3) !== 0) { + return false; + } + + $before = $index === 0 ? ' ' : $query[$index - 1]; + $after = $query[$index + 3] ?? ' '; + + return ctype_space($before) && ctype_space($after); + } + + private function parseLiteral(string $literal): bool|float|int|string + { + if ($literal === '') { + throw new ScheduleVisibilityQueryException('Schedule visibility predicate literal must not be empty.'); + } + + if ($literal[0] === '"') { + $decoded = json_decode($literal, true); + + if (! is_string($decoded) || json_last_error() !== JSON_ERROR_NONE) { + throw new ScheduleVisibilityQueryException( + sprintf('Schedule visibility literal [%s] is not a valid JSON string.', $literal), + ); + } + + return $decoded; + } + + if ($literal[0] === "'") { + return $this->parseSingleQuotedLiteral($literal); + } + + return $this->parseUnquotedLiteral($literal); + } + + private function parseSingleQuotedLiteral(string $literal): string + { + $index = 1; + $quote = "'"; + $length = strlen($literal); + + while ($index < $length && $quote !== null) { + [$index, $quote] = $this->quotedSegment($literal, $index, $quote); + } + + if ($quote !== null || $index !== $length) { + throw new ScheduleVisibilityQueryException( + sprintf('Schedule visibility literal [%s] is not a valid quoted string.', $literal), + ); + } + + return str_replace(["\\'", '\\\\'], ["'", '\\'], substr($literal, 1, -1)); + } + + private function parseUnquotedLiteral(string $literal): bool|float|int + { + return match (true) { + strcasecmp($literal, 'true') === 0 => true, + strcasecmp($literal, 'false') === 0 => false, + preg_match('/^-?(?:0|[1-9]\d*)$/', $literal) === 1 => $this->parseIntegerLiteral($literal), + preg_match('/^-?(?:0|[1-9]\d*)\.\d+$/', $literal) === 1 => $this->parseDecimalLiteral($literal), + default => throw new ScheduleVisibilityQueryException(sprintf( + 'Schedule visibility literal [%s] must be a quoted string, number, or boolean.', + $literal + )), + }; + } + + private function parseIntegerLiteral(string $literal): int + { + $value = filter_var($literal, FILTER_VALIDATE_INT); + + if ($value === false) { + throw new ScheduleVisibilityQueryException( + 'Schedule visibility integer literal is outside the supported integer range.', + ); + } + + return $value; + } + + private function parseDecimalLiteral(string $literal): float + { + $value = (float) $literal; + + if (! is_finite($value)) { + throw new ScheduleVisibilityQueryException('Schedule visibility numeric literal must be finite.'); + } + + return $value; + } + + private function validateLiteralType(string $field, string $type, bool|float|int|string $literal): void + { + $valid = is_string($literal) && ($type !== 'schedule_status' || in_array($literal, ['active', 'paused'], true)); + + if ($valid) { + return; + } + + throw new ScheduleVisibilityQueryException(sprintf( + 'Schedule visibility field [%s] requires a %s literal.', + $field, + $type === 'schedule_status' ? 'quoted active or paused' : 'quoted string', + )); + } +} diff --git a/src/V2/Support/ScheduleVisibilityQueryException.php b/src/V2/Support/ScheduleVisibilityQueryException.php new file mode 100644 index 000000000..1931c7ebe --- /dev/null +++ b/src/V2/Support/ScheduleVisibilityQueryException.php @@ -0,0 +1,17 @@ + $namespace) { + WorkflowSchedule::query()->create([ + 'namespace' => $namespace, + 'schedule_id' => 'namespace-' . $index, + 'status' => 'active', + 'action' => [ + 'workflow_type' => 'orders', + ], + ]); + } + $query = new ScheduleVisibilityQuery(); + $this->assertSame( + ['namespace-0'], + $query->query('ops', 'WorkflowType = "orders"') + ->pluck('schedule_id') + ->all() + ); + $this->assertSame( + ['namespace-1'], + $query->query('OPS', 'WorkflowType = "orders"') + ->pluck('schedule_id') + ->all() + ); + $this->assertSame( + ['namespace-2'], + $query->query('ops ', 'WorkflowType = "orders"') + ->pluck('schedule_id') + ->all() + ); + } + + public function testConfiguredScheduleTableAndConnectionAreAuthoritative(): void + { + $connection = 'visibility_query_fixture'; + $table = 'visibility_query_schedules'; + config() + ->set('database.connections.' . $connection, DB::connection()->getConfig()); + $database = DB::connection($connection); + $schema = $database->getSchemaBuilder(); + $schema->create($table, static function (Blueprint $blueprint): void { + $blueprint->string('id') + ->primary(); + $blueprint->string('schedule_id'); + $blueprint->string('namespace'); + $blueprint->string('status'); + $blueprint->json('action'); + }); + try { + $database->table($table) + ->insert([ + [ + 'id' => 'custom', + 'schedule_id' => 'custom', + 'namespace' => 'ops', + 'status' => 'active', + 'action' => json_encode([ + 'workflow_type' => 'orders', + ], JSON_THROW_ON_ERROR), + ], + [ + 'id' => 'private', + 'schedule_id' => 'private', + 'namespace' => 'private', + 'status' => 'active', + 'action' => json_encode([ + 'workflow_type' => 'orders', + ], JSON_THROW_ON_ERROR), + ], + ]); + WorkflowSchedule::query()->create([ + 'namespace' => 'ops', + 'schedule_id' => 'wrong-default', + 'status' => 'active', + 'action' => [ + 'workflow_type' => 'orders', + ], + ]); + config() + ->set('workflows.v2.schedule_model', VisibilityQueryCustomSchedule::class); + $builder = (new ScheduleVisibilityQuery())->query('ops', 'WorkflowType = "orders"'); + $this->assertInstanceOf(VisibilityQueryCustomSchedule::class, $builder->getModel()); + $this->assertSame($connection, $builder->getModel()->getConnectionName()); + $this->assertSame(['custom'], $builder->pluck('schedule_id')->all()); + } finally { + config()->set('workflows.v2.schedule_model', WorkflowSchedule::class); + $schema->dropIfExists($table); + DB::purge($connection); + } + } +} + +final class VisibilityQueryCustomSchedule extends WorkflowSchedule +{ + protected $table = 'visibility_query_schedules'; + + protected $connection = 'visibility_query_fixture'; +} diff --git a/tests/Feature/V2/ScheduleVisibilityQueryTest.php b/tests/Feature/V2/ScheduleVisibilityQueryTest.php new file mode 100644 index 000000000..432020def --- /dev/null +++ b/tests/Feature/V2/ScheduleVisibilityQueryTest.php @@ -0,0 +1,117 @@ +schedule('match-' . $index, 'ops', 'orders'); + } + $this->schedule('other-type', 'ops', 'payments'); + $this->schedule('other-namespace', 'private', 'orders'); + $this->schedule('deleted', 'ops', 'orders', 'deleted'); + $query = new ScheduleVisibilityQuery(); + $filter = 'WorkflowType = "orders" AND Status = "active" AND TaskQueue = "slow"'; + $this->assertSame(51, $query->query('ops', $filter)->count()); + $first = $query->query('ops', $filter) + ->orderBy('schedule_id') + ->paginate(50, ['*'], 'page', 1); + $second = $query->query('ops', $filter) + ->orderBy('schedule_id') + ->paginate(50, ['*'], 'page', 2); + $this->assertSame(51, $first->total()); + $this->assertCount(50, $first->items()); + $this->assertCount(1, $second->items()); + foreach ([...$first->items(), ...$second->items()] as $schedule) { + $this->assertSame('ops', $schedule->namespace); + $this->assertSame('orders', $schedule->workflow_type); + } + } + + public function testLiteralSqlAndQuotedKeywordsStayBoundValues(): void + { + $literal = "x' OR 1=1; -- AND y"; + $schedule = $this->schedule('literal', 'ops', 'orders'); + $schedule->update([ + 'note' => $literal, + ]); + $this->schedule('unrelated', 'private', 'orders'); + $filter = 'Note = ' . json_encode($literal, JSON_THROW_ON_ERROR); + $query = (new ScheduleVisibilityQuery())->query('ops', $filter); + $this->assertContains($literal, $query->getBindings()); + $this->assertStringNotContainsString($literal, $query->toSql()); + $this->assertSame(['literal'], $query->pluck('schedule_id')->all()); + $this->assertSame(2, WorkflowSchedule::query()->count()); + } + + public function testUnsupportedAttributesRefuseInsteadOfReturningAnEmptyPage(): void + { + $this->schedule('retained', 'ops', 'orders') + ->update([ + 'search_attributes' => [ + 'Team' => 'gold', + ], + ]); + try { + (new ScheduleVisibilityQuery())->query('ops', 'Team = "gold"') + ->count(); + $this->fail('An unsupported attribute must not become an empty or unfiltered list.'); + } catch (ScheduleVisibilityQueryException $error) { + $this->assertSame('unsupported_schedule_visibility_field', $error->reason); + } + $this->assertSame(1, WorkflowSchedule::query()->count()); + } + + public function testAnEmptyNamespaceCannotCreateAnUnscopedQuery(): void + { + $this->expectException(ScheduleVisibilityQueryException::class); + (new ScheduleVisibilityQuery())->query(' ', 'Status = "active"'); + } + + protected function defineEnvironment($app): void + { + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + $app['config']->set('cache.default', 'array'); + } + + protected function defineDatabaseMigrations(): void + { + $migration = require __DIR__ . '/../../../src/migrations/2026_04_14_000157_create_workflow_schedules_table.php'; + $migration->up(); + } + + private function schedule(string $id, string $namespace, string $type, string $status = 'active'): WorkflowSchedule + { + return WorkflowSchedule::query()->create([ + 'namespace' => $namespace, + 'schedule_id' => $id, + 'status' => $status, + 'spec' => [ + 'intervals' => [[ + 'every' => 'PT1H', + ]], + 'timezone' => 'UTC', + ], + 'action' => [ + 'workflow_type' => $type, + 'task_queue' => 'slow', + ], + 'overlap_policy' => 'skip', + 'jitter_seconds' => 0, + ]); + } +} diff --git a/tests/Fixtures/ScheduleVisibilityQuery/system-fields.json b/tests/Fixtures/ScheduleVisibilityQuery/system-fields.json new file mode 100644 index 000000000..8e1a020ac --- /dev/null +++ b/tests/Fixtures/ScheduleVisibilityQuery/system-fields.json @@ -0,0 +1,307 @@ +{ + "schema": "durable-workflow.schedule-visibility-system/v1", + "native_reference": "durable-workflow/server@c5777c475f8157ba92bd49789a0bc62ab653fb78:rust/src/runtime/schedule_list.rs", + "cases": [ + { + "name": "workflow_type", + "query": "WorkflowType = \"orders\"", + "predicates": [ + { + "field": "WorkflowType", + "column": "action->workflow_type", + "type": "keyword", + "literal": "orders" + } + ] + }, + { + "name": "status_and_queue", + "query": "Status = 'paused' AND TaskQueue = 'slow'", + "predicates": [ + { + "field": "Status", + "column": "status", + "type": "schedule_status", + "literal": "paused" + }, + { + "field": "TaskQueue", + "column": "action->task_queue", + "type": "keyword", + "literal": "slow" + } + ] + }, + { + "name": "quoted_keywords", + "query": "Note = 'x AND y OR z != IN'", + "predicates": [ + { + "field": "Note", + "column": "note", + "type": "text", + "literal": "x AND y OR z != IN" + } + ] + }, + { + "name": "escaped_quote", + "query": "ScheduleId = 'a\\'b' and Note = 'path\\\\file'", + "predicates": [ + { + "field": "ScheduleId", + "column": "schedule_id", + "type": "keyword", + "literal": "a'b" + }, + { + "field": "Note", + "column": "note", + "type": "text", + "literal": "path\\file" + } + ] + }, + { + "name": "unicode", + "query": "Note = \"注文\"", + "predicates": [ + { + "field": "Note", + "column": "note", + "type": "text", + "literal": "注文" + } + ] + }, + { + "name": "json_escape", + "query": "Note = \"line\\nquote\\\"\"", + "predicates": [ + { + "field": "Note", + "column": "note", + "type": "text", + "literal": "line\nquote\"" + } + ] + }, + { + "name": "empty_string", + "query": "Note = ''", + "predicates": [ + { + "field": "Note", + "column": "note", + "type": "text", + "literal": "" + } + ] + }, + { + "name": "whitespace", + "query": "\nStatus = 'active'\tAnD\nWorkflowType='orders' ", + "predicates": [ + { + "field": "Status", + "column": "status", + "type": "schedule_status", + "literal": "active" + }, + { + "field": "WorkflowType", + "column": "action->workflow_type", + "type": "keyword", + "literal": "orders" + } + ] + }, + { + "name": "not_equal", + "query": "Status != 'active'", + "reason": "unsupported_schedule_visibility_predicate" + }, + { + "name": "or", + "query": "Status='active' OR Note='x'", + "reason": "unsupported_schedule_visibility_predicate" + }, + { + "name": "in", + "query": "Status IN ('active')", + "reason": "unsupported_schedule_visibility_predicate" + }, + { + "name": "not", + "query": "NOT Status='active'", + "reason": "unsupported_schedule_visibility_predicate" + }, + { + "name": "attribute", + "query": "Team='gold'", + "reason": "unsupported_schedule_visibility_field" + }, + { + "name": "wrong_case", + "query": "workflowtype='orders'", + "reason": "unsupported_schedule_visibility_field" + }, + { + "name": "namespace_field", + "query": "Namespace='other'", + "reason": "unsupported_schedule_visibility_field" + }, + { + "name": "bad_status", + "query": "Status='deleted'", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "wrong_type", + "query": "WorkflowType=5", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "overflow_integer", + "query": "Note=9223372036854775808", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "nonfinite_decimal", + "query": "Note=9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999.0", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "null", + "query": "Note=null", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "grouping", + "query": "(Status='active')", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "unterminated", + "query": "Note='open", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "trailing_and", + "query": "Note='x' AND", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "empty", + "query": " ", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "operator_injection", + "query": "Note='x' OR ScheduleId='secret'", + "reason": "unsupported_schedule_visibility_predicate" + }, + { + "name": "unicode-space-edges", + "query": "  WorkflowType = \"orders\" ", + "predicates": [ + { + "field": "WorkflowType", + "column": "action->workflow_type", + "type": "keyword", + "literal": "orders" + } + ] + }, + { + "name": "bom-zero-width-edges", + "query": "​‎WorkflowType = \"orders\"‎", + "predicates": [ + { + "field": "WorkflowType", + "column": "action->workflow_type", + "type": "keyword", + "literal": "orders" + } + ] + }, + { + "name": "literal-zero-width-preserved", + "query": "Note = \"​\"", + "predicates": [ + { + "field": "Note", + "column": "note", + "type": "text", + "literal": "​" + } + ] + }, + { + "name": "empty-after-request-normalization", + "query": "​‎  ", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "adjacent_single_quoted_space", + "query": "Note = 'a' 'b'", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "adjacent_single_quoted_no_space", + "query": "Note = 'a''b'", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "adjacent_single_quoted_tab", + "query": "Note = 'a'\t'b'", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "adjacent_single_quoted_before_and", + "query": "Note = 'a' 'b' AND Status = 'active'", + "reason": "invalid_schedule_visibility_query" + }, + { + "name": "single_quoted_or_refused", + "query": "Note = 'a' OR 'b'", + "reason": "unsupported_schedule_visibility_predicate" + }, + { + "name": "escaped_backslash_and_quote", + "query": "Note = 'a\\\\\\'b'", + "predicates": [ + { + "field": "Note", + "column": "note", + "type": "text", + "literal": "a\\'b" + } + ] + }, + { + "name": "escaped_trailing_backslash", + "query": "Note = 'a\\\\'", + "predicates": [ + { + "field": "Note", + "column": "note", + "type": "text", + "literal": "a\\" + } + ] + }, + { + "name": "unknown_single_quote_escape_preserved", + "query": "Note = 'path\\q'", + "predicates": [ + { + "field": "Note", + "column": "note", + "type": "text", + "literal": "path\\q" + } + ] + } + ] +} diff --git a/tests/Unit/V2/ScheduleVisibilityQueryTest.php b/tests/Unit/V2/ScheduleVisibilityQueryTest.php new file mode 100644 index 000000000..873fceadf --- /dev/null +++ b/tests/Unit/V2/ScheduleVisibilityQueryTest.php @@ -0,0 +1,51 @@ +parse($case['query']); + $this->assertArrayHasKey('predicates', $case, $case['name']); + $this->assertSame($case['predicates'], $actual, $case['name']); + } catch (ScheduleVisibilityQueryException $error) { + $this->assertArrayHasKey('reason', $case, $case['name']); + $this->assertSame($case['reason'], $error->reason, $case['name']); + } + } + } + + public function testQueryLengthCountsUnicodeCharactersAfterRequestNormalization(): void + { + $query = new ScheduleVisibilityQuery(); + $this->assertSame( + str_repeat('🙂', 2039), + $query->parse('Note = "' . str_repeat('🙂', 2039) . '"')[0]['literal'] + ); + $padded = "\u{FEFF}\u{00A0} " . 'Note = "' . str_repeat('🙂', 2039) . '"' . "\u{200B}\u{200E}"; + $this->assertSame(str_repeat('🙂', 2039), $query->parse($padded)[0]['literal']); + $this->expectException(ScheduleVisibilityQueryException::class); + $query->parse('Note = "' . str_repeat('🙂', 2040) . '"'); + } + + public function testMalformedUtf8IsRefused(): void + { + $this->expectException(ScheduleVisibilityQueryException::class); + (new ScheduleVisibilityQuery())->parse("Note = \"\xff\""); + } +}