diff --git a/src/DataObject/Filter.php b/src/DataObject/Filter.php new file mode 100644 index 0000000..3cf44ad --- /dev/null +++ b/src/DataObject/Filter.php @@ -0,0 +1,207 @@ + 'published', // = 'published' + * 'type' => [ 'video', 'text' ], // IN ('video', 'text') + * 'superseded_by' => Filter::is_null(), // IS NULL + * 'weight' => Filter::at_least( 10 ), // >= 10 + * ] ); + * + * matches() defines the reference semantics every storage translation + * must preserve (see ListQuery): + * + * - Equality is string-loose: filter values usually arrive from URLs as + * strings while stored values may be int, bool or null. + * - IS NULL / IS NOT NULL test the PHP null, never the empty string. + * - Comparisons use the same rule as ordering: numeric pairs compare + * numerically, everything else case-insensitively as strings, and null + * sorts before every value. Storages that compare text columns under + * a collation may differ from this rule for numeric-looking strings. + */ +final class Filter { + + public const EQUALS = '='; + public const NOT_EQUALS = '!='; + public const LESS_THAN = '<'; + public const AT_MOST = '<='; + public const GREATER_THAN = '>'; + public const AT_LEAST = '>='; + public const IN = 'in'; + public const IS_NULL = 'null'; + public const IS_NOT_NULL = 'not_null'; + + /** + * Operators whose operand must be a single scalar. + */ + private const SCALAR_OPERATORS = [ + self::EQUALS, + self::NOT_EQUALS, + self::LESS_THAN, + self::AT_MOST, + self::GREATER_THAN, + self::AT_LEAST, + ]; + + /** + * @param string $operator One of the operator constants. + * @param mixed $value The operand: a scalar, a scalar[] for IN, null for the null tests. + */ + private function __construct( + public readonly string $operator, + public readonly mixed $value = null + ) { + if ( in_array( $operator, self::SCALAR_OPERATORS, true ) && ! is_scalar( $value ) ) { + throw new InvalidArgumentException( "Filter operator '{$operator}' requires a scalar operand." ); + } + if ( $operator === self::IN ) { + if ( ! is_array( $value ) ) { + throw new InvalidArgumentException( 'Filter operator IN requires an array operand.' ); + } + foreach ( $value as $item ) { + if ( ! is_scalar( $item ) ) { + throw new InvalidArgumentException( 'Filter operator IN requires scalar list items.' ); + } + } + } + } + + /** + * Normalize a ListQuery filter value into a Filter. + * + * Scalars become equality, arrays become IN, Filters pass through. + * + * @param mixed $value A scalar, a scalar[] or a Filter. + * @return self The constraint. + */ + public static function from( mixed $value ): self { + if ( $value instanceof self ) { + return $value; + } + if ( is_array( $value ) ) { + return self::in( $value ); + } + if ( $value === null ) { + // A bare null has always stringified to '' under the loose + // equality rule; keep that rather than silently turning it + // into IS NULL. Callers wanting the null test say so. + return self::equals( '' ); + } + return self::equals( $value ); + } + + public static function equals( int|float|string|bool $value ): self { + return new self( self::EQUALS, $value ); + } + + public static function not_equals( int|float|string|bool $value ): self { + return new self( self::NOT_EQUALS, $value ); + } + + public static function less_than( int|float|string|bool $value ): self { + return new self( self::LESS_THAN, $value ); + } + + public static function at_most( int|float|string|bool $value ): self { + return new self( self::AT_MOST, $value ); + } + + public static function greater_than( int|float|string|bool $value ): self { + return new self( self::GREATER_THAN, $value ); + } + + public static function at_least( int|float|string|bool $value ): self { + return new self( self::AT_LEAST, $value ); + } + + /** + * @param array $values Accepted values; an empty list matches nothing. + */ + public static function in( array $values ): self { + return new self( self::IN, array_values( $values ) ); + } + + public static function is_null(): self { + return new self( self::IS_NULL ); + } + + public static function is_not_null(): self { + return new self( self::IS_NOT_NULL ); + } + + /** + * Whether a stored value satisfies this constraint (reference semantics). + * + * @param mixed $actual The stored field value: a scalar or null. + * @return bool True when the value passes. + */ + public function matches( mixed $actual ): bool { + if ( ! is_scalar( $actual ) && $actual !== null ) { + return false; + } + + switch ( $this->operator ) { + case self::IS_NULL: + return $actual === null; + case self::IS_NOT_NULL: + return $actual !== null; + case self::IN: + foreach ( $this->value as $candidate ) { + if ( (string) $actual === (string) $candidate ) { + return true; + } + } + return false; + case self::EQUALS: + return (string) $actual === (string) $this->value; + case self::NOT_EQUALS: + return (string) $actual !== (string) $this->value; + case self::LESS_THAN: + return self::compare( $actual, $this->value ) < 0; + case self::AT_MOST: + return self::compare( $actual, $this->value ) <= 0; + case self::GREATER_THAN: + return self::compare( $actual, $this->value ) > 0; + case self::AT_LEAST: + return self::compare( $actual, $this->value ) >= 0; + } + + return false; + } + + /** + * Compare two field values, the rule shared by comparison filters + * and ordering. + * + * Numeric pairs compare numerically, everything else compares as + * case-insensitive strings. Nulls sort before any value. + * + * @param mixed $a First value. + * @param mixed $b Second value. + * @return int Spaceship-style comparison result. + */ + public static function compare( mixed $a, mixed $b ): int { + if ( $a === null || $b === null ) { + return ( $a === null ? 0 : 1 ) <=> ( $b === null ? 0 : 1 ); + } + if ( is_numeric( $a ) && is_numeric( $b ) ) { + return $a <=> $b; + } + return strcasecmp( (string) $a, (string) $b ); + } +} diff --git a/src/DataObject/ListQuery.php b/src/DataObject/ListQuery.php index 472f9e4..cd3365e 100644 --- a/src/DataObject/ListQuery.php +++ b/src/DataObject/ListQuery.php @@ -19,11 +19,13 @@ * - The DataView RequestRouter builds it from list-page request * parameters (paged / orderby / order / s / filter_*). * - * The in-memory helpers define the reference semantics: filters are - * string-loose equality, search is a case-insensitive substring match - * over the declared search fields, ordering compares numerically when - * both values are numeric and case-insensitively otherwise. Storage - * implementations should preserve these semantics. + * The in-memory helpers define the reference semantics: a plain filter + * value is string-loose equality and a Filter instance selects another + * operator (IN, IS NULL, comparisons — see Filter::matches()), search + * is a case-insensitive substring match over the declared search + * fields, ordering compares numerically when both values are numeric + * and case-insensitively otherwise, key by key when several are given. + * Storage implementations should preserve these semantics. */ class ListQuery { @@ -42,19 +44,30 @@ class ListQuery { public readonly int $per_page; /** - * Field to order by. Empty string preserves storage order. + * Primary field to order by. Empty string preserves storage order. + * + * The first key of $ordering, kept for callers that only know a + * single sort column. * * @var string */ public readonly string $orderby; /** - * Order direction: 'asc' or 'desc'. + * Direction of the primary order field: 'asc' or 'desc'. * * @var string */ public readonly string $order; + /** + * The full ordering, field => 'asc'|'desc', in priority order. + * Empty preserves storage order. + * + * @var array + */ + public readonly array $ordering; + /** * Search term. Empty string means no search. * @@ -71,27 +84,39 @@ class ListQuery { public readonly array $search_fields; /** - * Field filters as field => value equality constraints. + * Field filters as given: field => scalar (equality), scalar[] (IN) + * or Filter. See constraints() for the normalized form. * - * @var array + * @var array */ public readonly array $filters; + /** + * The filters normalized to Filter instances, field => Filter. + * + * @var array + */ + private array $constraints; + /** * Create a new ListQuery, normalizing out-of-range values. * - * @param int $page Page number (clamped to >= 1). - * @param int $per_page Items per page (clamped to >= 0; 0 = unpaginated). - * @param string $orderby Field to order by ('' = storage order). - * @param string $order 'asc' or 'desc' (anything else becomes 'asc'). - * @param string $search Search term. - * @param string[] $search_fields Fields to search in. - * @param array $filters Field => value equality filters. + * Ordering accepts a single field name, or an array for multi-column + * ordering: field => 'asc'|'desc' pairs in priority order, or a plain + * list of field names that all take $order. + * + * @param int $page Page number (clamped to >= 1). + * @param int $per_page Items per page (clamped to >= 0; 0 = unpaginated). + * @param string|array $orderby Field to order by ('' = storage order), or an ordering array. + * @param string $order 'asc' or 'desc' (anything else becomes 'asc'). + * @param string $search Search term. + * @param string[] $search_fields Fields to search in. + * @param array $filters Field => scalar, scalar[] or Filter constraints. */ public function __construct( int $page = 1, int $per_page = 20, - string $orderby = '', + string|array $orderby = '', string $order = 'asc', string $search = '', array $search_fields = [], @@ -99,11 +124,32 @@ public function __construct( ) { $this->page = max( 1, $page ); $this->per_page = max( 0, $per_page ); - $this->orderby = $orderby; - $this->order = strtolower( $order ) === 'desc' ? 'desc' : 'asc'; $this->search = $search; $this->search_fields = array_values( $search_fields ); $this->filters = $filters; + + $default_order = strtolower( $order ) === 'desc' ? 'desc' : 'asc'; + $ordering = []; + + foreach ( is_string( $orderby ) ? [ $orderby ] : $orderby as $key => $value ) { + if ( is_int( $key ) ) { + $field = (string) $value; + $direction = $default_order; + } else { + $field = (string) $key; + $direction = strtolower( (string) $value ) === 'desc' ? 'desc' : 'asc'; + } + if ( $field === '' || isset( $ordering[ $field ] ) ) { + continue; + } + $ordering[ $field ] = $direction; + } + + $this->ordering = $ordering; + $this->orderby = (string) ( array_key_first( $ordering ) ?? '' ); + $this->order = $ordering[ $this->orderby ] ?? $default_order; + + $this->constraints = array_map( [ Filter::class, 'from' ], $filters ); } /** @@ -115,6 +161,16 @@ public function offset(): int { return $this->per_page > 0 ? ( $this->page - 1 ) * $this->per_page : 0; } + /** + * The filters as Filter instances, field => Filter, so storages can + * translate operators without repeating the shorthand rules. + * + * @return array The normalized constraints. + */ + public function constraints(): array { + return $this->constraints; + } + /** * Whether a data row matches the search term and filters. * @@ -122,17 +178,13 @@ public function offset(): int { * @return bool True when the row survives search and filters. */ public function matches( array $row ): bool { - foreach ( $this->filters as $field => $value ) { + foreach ( $this->constraints as $field => $filter ) { + // A filter on a field the row does not have matches nothing, + // whatever the operator. if ( ! array_key_exists( $field, $row ) ) { return false; } - $actual = $row[ $field ]; - if ( ! is_scalar( $actual ) && $actual !== null ) { - return false; - } - // String-loose equality: filter values usually arrive from - // URLs as strings while stored values may be int or bool. - if ( (string) $actual !== (string) $value ) { + if ( ! $filter->matches( $row[ $field ] ) ) { return false; } } @@ -192,14 +244,18 @@ public function apply( array $items, ?callable $accessor = null ): array { fn( $item ) => $this->matches( $row( $item ) ) ) ); - if ( $this->orderby !== '' ) { + if ( $this->ordering !== [] ) { // usort() is stable in PHP 8, so equal keys keep storage order. usort( $matched, function ( $a, $b ) use ( $row ) { - $result = $this->compare_values( - $row( $a )[ $this->orderby ] ?? null, - $row( $b )[ $this->orderby ] ?? null - ); - return $this->order === 'desc' ? -$result : $result; + $row_a = $row( $a ); + $row_b = $row( $b ); + foreach ( $this->ordering as $field => $direction ) { + $result = $this->compare_values( $row_a[ $field ] ?? null, $row_b[ $field ] ?? null ); + if ( $result !== 0 ) { + return $direction === 'desc' ? -$result : $result; + } + } + return 0; } ); } @@ -214,19 +270,14 @@ public function apply( array $items, ?callable $accessor = null ): array { * Compare two field values for ordering. * * Numeric pairs compare numerically, everything else compares as - * case-insensitive strings. Nulls sort before any value. + * case-insensitive strings. Nulls sort before any value. The same + * rule drives comparison filters (Filter::compare()). * * @param mixed $a First value. * @param mixed $b Second value. * @return int Spaceship-style comparison result. */ protected function compare_values( mixed $a, mixed $b ): int { - if ( $a === null || $b === null ) { - return ( $a === null ? 0 : 1 ) <=> ( $b === null ? 0 : 1 ); - } - if ( is_numeric( $a ) && is_numeric( $b ) ) { - return $a <=> $b; - } - return strcasecmp( (string) $a, (string) $b ); + return Filter::compare( $a, $b ); } } diff --git a/src/DataObject/Storage/DatabaseModuleStorage.php b/src/DataObject/Storage/DatabaseModuleStorage.php index 40d8189..76d2422 100644 --- a/src/DataObject/Storage/DatabaseModuleStorage.php +++ b/src/DataObject/Storage/DatabaseModuleStorage.php @@ -2,6 +2,7 @@ namespace Tangible\DataObject\Storage; +use Tangible\DataObject\Filter; use Tangible\DataObject\ListQuery; use Tangible\DataObject\QueryablePluralStorage; use TDB_Table; @@ -14,9 +15,10 @@ * table storage for entities. * * Schema fields are real table columns, so ListQuery executes natively: - * filters and search become a prepared WHERE clause, ordering becomes - * ORDER BY on a schema-whitelisted column, pagination becomes LIMIT/OFFSET. - * Nothing outside the requested page is loaded into PHP. + * filters (equality, IN, IS NULL, comparisons) and search become a + * prepared WHERE clause, ordering becomes ORDER BY on schema-whitelisted + * columns, pagination becomes LIMIT/OFFSET. Nothing outside the requested + * page is loaded into PHP. * * @see https://bitbucket.org/tangibleinc/tangible-database-module */ @@ -217,12 +219,12 @@ protected function build_where( ListQuery $query ): string { $columns = $this->schema_columns(); $clauses = []; - foreach ( $query->filters as $field => $value ) { + foreach ( $query->constraints() as $field => $filter ) { $column = $this->normalize_column( (string) $field ); if ( ! in_array( $column, $columns, true ) ) { return ' WHERE 1 = 0'; } - $clauses[] = $wpdb->prepare( "`{$column}` = %s", (string) $value ); + $clauses[] = $this->build_filter_clause( $column, $filter ); } if ( $query->search !== '' ) { @@ -249,25 +251,80 @@ protected function build_where( ListQuery $query ): string { } /** - * Build the ORDER BY clause. Unknown order fields preserve storage - * order, matching the in-memory fallback. + * Translate one Filter into a prepared SQL predicate on a + * whitelisted column. + * + * Mirrors Filter::matches(): there a stored null stringifies to '' + * for equality and sorts before every value for comparisons, so the + * SQL keeps NULL rows wherever the in-memory rule would keep them + * (<> against a non-empty value, < and <=). + * + * @param string $column Backtick-safe column name. + * @param Filter $filter The constraint. + * @return string SQL predicate. + */ + protected function build_filter_clause( string $column, Filter $filter ): string { + $wpdb = $GLOBALS['wpdb']; + $value = is_scalar( $filter->value ) ? (string) $filter->value : ''; + + switch ( $filter->operator ) { + case Filter::IS_NULL: + return "`{$column}` IS NULL"; + + case Filter::IS_NOT_NULL: + return "`{$column}` IS NOT NULL"; + + case Filter::IN: + if ( $filter->value === [] ) { + return '1 = 0'; + } + $placeholders = implode( ', ', array_fill( 0, count( $filter->value ), '%s' ) ); + return $wpdb->prepare( + "`{$column}` IN ( {$placeholders} )", + array_map( 'strval', $filter->value ) + ); + + case Filter::NOT_EQUALS: + if ( $value === '' ) { + return $wpdb->prepare( "`{$column}` <> %s", $value ); + } + return $wpdb->prepare( "( `{$column}` IS NULL OR `{$column}` <> %s )", $value ); + + case Filter::LESS_THAN: + case Filter::AT_MOST: + return $wpdb->prepare( "( `{$column}` IS NULL OR `{$column}` {$filter->operator} %s )", $value ); + + case Filter::GREATER_THAN: + case Filter::AT_LEAST: + return $wpdb->prepare( "`{$column}` {$filter->operator} %s", $value ); + + case Filter::EQUALS: + default: + return $wpdb->prepare( "`{$column}` = %s", $value ); + } + } + + /** + * Build the ORDER BY clause, one term per ordering key. Unknown + * order fields are skipped — in the in-memory fallback they compare + * equal for every row and fall through to the next key. * * @param ListQuery $query The list query. * @return string Leading-space ORDER BY clause, or empty string. */ protected function build_order( ListQuery $query ): string { - if ( $query->orderby === '' ) { - return ''; - } + $columns = $this->schema_columns(); + $terms = []; - $column = $this->normalize_column( $query->orderby ); - if ( ! in_array( $column, $this->schema_columns(), true ) ) { - return ''; + foreach ( $query->ordering as $field => $direction ) { + $column = $this->normalize_column( (string) $field ); + if ( ! in_array( $column, $columns, true ) ) { + continue; + } + $terms[] = "`{$column}` " . ( $direction === 'desc' ? 'DESC' : 'ASC' ); } - $direction = $query->order === 'desc' ? 'DESC' : 'ASC'; - - return " ORDER BY `{$column}` {$direction}"; + return $terms === [] ? '' : ' ORDER BY ' . implode( ', ', $terms ); } /** diff --git a/tests/phpunit/list-query.php b/tests/phpunit/list-query.php index 6dc420b..41fdb20 100644 --- a/tests/phpunit/list-query.php +++ b/tests/phpunit/list-query.php @@ -2,6 +2,7 @@ namespace Tangible\Object\Tests; use Tangible\DataObject\DataSet; +use Tangible\DataObject\Filter; use Tangible\DataObject\ListQuery; use Tangible\DataObject\PluralObject; use Tangible\DataObject\PluralObject\Entity; @@ -19,6 +20,7 @@ * and the RequestRouter's list rendering. * * @covers \Tangible\DataObject\ListQuery + * @covers \Tangible\DataObject\Filter * @covers \Tangible\DataObject\QueryablePluralStorage * @covers \Tangible\DataObject\PluralObject * @covers \Tangible\DataObject\Storage\DatabaseModuleStorage @@ -151,6 +153,159 @@ public function test_apply_accepts_an_accessor_for_non_row_items(): void { $this->assertSame( 2, $query->count_matching( $entities, $accessor ) ); } + /** + * ========================================================================== + * ListQuery: filter operators + * ========================================================================== + */ + + /** + * sample_rows() plus a nullable 'parent' and a row with a null weight, + * so the null rules have something to bite on. + */ + private function nullable_rows(): array { + return [ + [ 'id' => 1, 'title' => 'Alpha video', 'type' => 'video', 'weight' => 10, 'parent' => null ], + [ 'id' => 2, 'title' => 'beta text', 'type' => 'text', 'weight' => 2, 'parent' => 1 ], + [ 'id' => 3, 'title' => 'Gamma VIDEO guide', 'type' => 'video', 'weight' => 30, 'parent' => null ], + [ 'id' => 4, 'title' => 'Delta text', 'type' => 'text', 'weight' => 2, 'parent' => 3 ], + [ 'id' => 5, 'title' => 'Epsilon text', 'type' => 'text', 'weight' => null, 'parent' => 1 ], + ]; + } + + private function ids_matching( array $filters ): array { + return array_column( + ( new ListQuery( per_page: 0, filters: $filters ) )->apply( $this->nullable_rows() ), + 'id' + ); + } + + public function test_plain_filter_values_normalize_to_filters(): void { + $this->assertSame( Filter::EQUALS, Filter::from( 'x' )->operator ); + $this->assertSame( 'x', Filter::from( 'x' )->value ); + + $this->assertSame( Filter::IN, Filter::from( [ 'a', 'b' ] )->operator ); + $this->assertSame( [ 'a', 'b' ], Filter::from( [ 'k' => 'a', 'b' ] )->value ); + + // A bare null keeps the long-standing loose-equality reading ('') + // rather than silently becoming IS NULL. + $this->assertSame( Filter::EQUALS, Filter::from( null )->operator ); + $this->assertSame( '', Filter::from( null )->value ); + + $filter = Filter::is_null(); + $this->assertSame( $filter, Filter::from( $filter ) ); + + $query = new ListQuery( filters: [ 'type' => 'video', 'parent' => $filter ] ); + $this->assertSame( [ 'type' => 'video', 'parent' => $filter ], $query->filters ); + $this->assertContainsOnlyInstancesOf( Filter::class, $query->constraints() ); + $this->assertSame( [ 'type', 'parent' ], array_keys( $query->constraints() ) ); + } + + public function test_in_filter_rejects_non_scalar_items(): void { + $this->expectException( \InvalidArgumentException::class ); + + Filter::in( [ 'a', [ 'nested' ] ] ); + } + + public function test_null_filters_test_the_php_null_not_the_empty_string(): void { + $this->assertSame( [ 1, 3 ], $this->ids_matching( [ 'parent' => Filter::is_null() ] ) ); + $this->assertSame( [ 2, 4, 5 ], $this->ids_matching( [ 'parent' => Filter::is_not_null() ] ) ); + + // '' is a value, not a null. + $this->assertFalse( Filter::is_null()->matches( '' ) ); + $this->assertTrue( Filter::is_not_null()->matches( '' ) ); + } + + public function test_null_filter_on_missing_field_matches_nothing(): void { + $this->assertSame( [], $this->ids_matching( [ 'nonexistent' => Filter::is_null() ] ) ); + } + + public function test_not_equals_is_loose_and_keeps_null_rows(): void { + // Row 5's null weight stringifies to '' and so differs from '2'. + $this->assertSame( [ 1, 3, 5 ], $this->ids_matching( [ 'weight' => Filter::not_equals( 2 ) ] ) ); + $this->assertSame( [ 1, 3, 5 ], $this->ids_matching( [ 'weight' => Filter::not_equals( '2' ) ] ) ); + + // ... but equals '' under the same loose rule. + $this->assertSame( [ 1, 2, 3, 4 ], $this->ids_matching( [ 'weight' => Filter::not_equals( '' ) ] ) ); + } + + public function test_in_filter_matches_any_listed_value(): void { + $this->assertSame( [ 2, 4, 5 ], $this->ids_matching( [ 'type' => Filter::in( [ 'text', 'nope' ] ) ] ) ); + // Array shorthand. + $this->assertSame( [ 2, 4, 5 ], $this->ids_matching( [ 'type' => [ 'text', 'nope' ] ] ) ); + // Loose: numeric ids listed as strings. + $this->assertSame( [ 1, 3 ], $this->ids_matching( [ 'id' => [ '1', '3' ] ] ) ); + // An empty list matches nothing. + $this->assertSame( [], $this->ids_matching( [ 'type' => [] ] ) ); + } + + public function test_comparison_filters_follow_the_ordering_rule(): void { + // Numeric pairs compare numerically; null sorts before every value. + $this->assertSame( [ 2, 4, 5 ], $this->ids_matching( [ 'weight' => Filter::less_than( 10 ) ] ) ); + $this->assertSame( [ 1, 2, 4, 5 ], $this->ids_matching( [ 'weight' => Filter::at_most( '10' ) ] ) ); + $this->assertSame( [ 1, 3 ], $this->ids_matching( [ 'weight' => Filter::greater_than( 2 ) ] ) ); + $this->assertSame( [ 1, 3 ], $this->ids_matching( [ 'weight' => Filter::at_least( 10 ) ] ) ); + + // Strings compare case-insensitively: 'Alpha…' and 'beta…' < 'c'. + $this->assertSame( [ 1, 2 ], $this->ids_matching( [ 'title' => Filter::less_than( 'c' ) ] ) ); + } + + public function test_filters_on_different_fields_combine_as_and(): void { + $this->assertSame( + [ 2 ], + $this->ids_matching( [ 'type' => 'text', 'weight' => Filter::is_not_null(), 'parent' => Filter::less_than( 3 ) ] ) + ); + } + + /** + * ========================================================================== + * ListQuery: multi-column ordering + * ========================================================================== + */ + + public function test_ordering_array_sorts_key_by_key(): void { + $rows = $this->sample_rows(); + $query = new ListQuery( per_page: 0, orderby: [ 'type' => 'asc', 'weight' => 'desc' ] ); + + // text (2, 4 — equal weights keep storage order), then video 30 before 10. + $this->assertSame( [ 2, 4, 3, 1 ], array_column( $query->apply( $rows ), 'id' ) ); + $this->assertSame( [ 'type' => 'asc', 'weight' => 'desc' ], $query->ordering ); + + // The single-column view exposes the primary key. + $this->assertSame( 'type', $query->orderby ); + $this->assertSame( 'asc', $query->order ); + } + + public function test_ordering_list_takes_the_shared_direction(): void { + $query = new ListQuery( per_page: 0, orderby: [ 'type', 'weight' ], order: 'DESC' ); + + $this->assertSame( [ 'type' => 'desc', 'weight' => 'desc' ], $query->ordering ); + $this->assertSame( [ 3, 1, 2, 4 ], array_column( $query->apply( $this->sample_rows() ), 'id' ) ); + } + + public function test_single_orderby_string_is_a_one_key_ordering(): void { + $query = new ListQuery( orderby: 'weight', order: 'desc' ); + $this->assertSame( [ 'weight' => 'desc' ], $query->ordering ); + + $empty = new ListQuery(); + $this->assertSame( [], $empty->ordering ); + $this->assertSame( '', $empty->orderby ); + $this->assertSame( 'asc', $empty->order ); + } + + public function test_ordering_ignores_blank_and_repeated_fields(): void { + $query = new ListQuery( orderby: [ '' => 'desc', 'type' => 'asc', 'type' => 'desc', 'weight' ] ); + + $this->assertSame( [ 'type' => 'desc', 'weight' => 'asc' ], $query->ordering ); + } + + public function test_unknown_ordering_key_falls_through_to_the_next(): void { + $rows = $this->sample_rows(); + $query = new ListQuery( per_page: 0, orderby: [ 'nonexistent' => 'asc', 'weight' => 'desc' ] ); + + $this->assertSame( [ 3, 1, 2, 4 ], array_column( $query->apply( $rows ), 'id' ) ); + } + /** * ========================================================================== * PluralObject: in-memory fallback over a non-queryable storage @@ -321,6 +476,12 @@ private function make_tdb_storage( string $slug ): DatabaseModuleStorage { 'type' => 'bigint', 'length' => '20', ], + 'parent' => [ + 'type' => 'bigint', + 'length' => '20', + 'nullable' => true, + 'default' => null, + ], ], ] ); @@ -329,9 +490,10 @@ private function make_tdb_storage( string $slug ): DatabaseModuleStorage { private function seed_tdb( DatabaseModuleStorage $storage ): void { $storage->insert( [ 'title' => 'Alpha video', 'category' => 'video', 'weight' => 10 ] ); - $storage->insert( [ 'title' => 'beta text', 'category' => 'text', 'weight' => 2 ] ); + $storage->insert( [ 'title' => 'beta text', 'category' => 'text', 'weight' => 2, 'parent' => 1 ] ); $storage->insert( [ 'title' => 'Gamma VIDEO guide', 'category' => 'video', 'weight' => 30 ] ); - $storage->insert( [ 'title' => 'Delta text', 'category' => 'text', 'weight' => 2 ] ); + $storage->insert( [ 'title' => 'Delta text', 'category' => 'text', 'weight' => 2, 'parent' => 3 ] ); + $storage->insert( [ 'title' => 'Epsilon text', 'category' => 'text', 'weight' => 5, 'parent' => 1 ] ); } public function test_tdb_storage_implements_queryable_interface(): void { @@ -358,6 +520,23 @@ public function test_tdb_query_matches_in_memory_reference_semantics(): void { new ListQuery( page: 2, per_page: 2, orderby: 'title', order: 'asc' ), new ListQuery( per_page: 0, filters: [ 'nonexistent' => 'x' ] ), new ListQuery( per_page: 0, search: 'anything', search_fields: [ 'nonexistent' ] ), + // Filter operators, including the NULL edge cases. + new ListQuery( per_page: 0, filters: [ 'parent' => Filter::is_null() ] ), + new ListQuery( per_page: 0, filters: [ 'parent' => Filter::is_not_null() ] ), + new ListQuery( per_page: 0, filters: [ 'parent' => Filter::not_equals( 1 ) ] ), + new ListQuery( per_page: 0, filters: [ 'parent' => Filter::not_equals( '' ) ] ), + new ListQuery( per_page: 0, filters: [ 'parent' => [ 1, 3 ] ] ), + new ListQuery( per_page: 0, filters: [ 'category' => [] ] ), + new ListQuery( per_page: 0, filters: [ 'parent' => Filter::less_than( 3 ) ] ), + new ListQuery( per_page: 0, filters: [ 'parent' => Filter::at_most( 1 ) ] ), + new ListQuery( per_page: 0, filters: [ 'weight' => Filter::greater_than( 2 ) ] ), + new ListQuery( per_page: 0, filters: [ 'weight' => Filter::at_least( 10 ) ] ), + new ListQuery( per_page: 0, filters: [ 'nonexistent' => Filter::is_null() ] ), + new ListQuery( per_page: 0, filters: [ 'category' => 'text', 'parent' => Filter::is_not_null() ], search: 'text', search_fields: [ 'title' ] ), + // Multi-column ordering, with and without an unknown key. + new ListQuery( per_page: 0, orderby: [ 'category' => 'asc', 'title' => 'desc' ] ), + new ListQuery( page: 2, per_page: 2, orderby: [ 'category', 'nonexistent', 'title' ], order: 'desc' ), + new ListQuery( per_page: 0, orderby: [ 'parent' => 'desc', 'id' => 'desc' ] ), ]; $all = $storage->all();