diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed95ac..f2bb264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,19 @@ All notable changes to this project will be documented in this file. This project adhere to the [Semantic Versioning](http://semver.org/) standard. +## [3.1.4] 2025-10-16 + +* Fix - Handle array values correctly in the `get_*` query methods. + ## [3.1.3] 2025-10-08 -* Fix - Fix the `get_current_schema` method to cache the schema of each implementation. +* Fix - `get_current_schema` method will npw cache the schema of each implementation correctly. [3.1.3]: https://github.com/stellarwp/schema/releases/tag/3.1.3 ## [3.1.2] 2025-10-02 -* Fix - Fix the `update_many` method to properly check if the transaction was successful. +* Fix - `update_many` method will now properly check if the transaction was successful. [3.1.2]: https://github.com/stellarwp/schema/releases/tag/3.1.2 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e485880..1ea8d3c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -20,6 +20,7 @@ parameters: level: 5 inferPrivatePropertyTypeFromConstructor: true reportUnmatchedIgnoredErrors: false + treatPhpDocTypesAsCertain: false # Paths to be analyzed. paths: diff --git a/src/Schema/Traits/Custom_Table_Query_Methods.php b/src/Schema/Traits/Custom_Table_Query_Methods.php index d7383b3..095c065 100644 --- a/src/Schema/Traits/Custom_Table_Query_Methods.php +++ b/src/Schema/Traits/Custom_Table_Query_Methods.php @@ -336,8 +336,9 @@ public static function get_total_items( array $args = [] ): int { * Updates multiple rows into the table. * * @since 3.0.0 + * @since 3.1.4 Enabled unfolding the value if is an array. * - * @param array $entries The entries to update. + * @param array> $entries The entries to update. * * @return bool Whether the update was successful. */ @@ -372,7 +373,7 @@ public static function update_many( array $entries ): bool { [ $value, $placeholder ] = self::prepare_value_for_query( $column, $value ); - $set_statement[] = $database::prepare( "%i = {$placeholder}", ...array_filter( [ $column, $value ], static fn( $v ) => null !== $v ) ); + $set_statement[] = $database::prepare( "%i = {$placeholder}", ...array_filter( [ $column, ...self::ensure_array( $value ) ], static fn( $v ) => null !== $v ) ); } $set_statement = implode( ', ', $set_statement ); @@ -672,12 +673,13 @@ protected static function get_join_parts( string $join_table, string $join_condi * * @since 3.0.0 * @since 3.1.1 Added the $order_by parameter. + * @since 3.1.4 Enabled unfolding the value if is an array. * - * @param string $column The column to get the models by. - * @param mixed $value The value to get the models by. - * @param string $operator The operator to use. - * @param int $limit The limit of models to return. - * @param string $order_by The order by clause to use. + * @param string $column The column to get the models by. + * @param null|int|string|float|bool|DateTimeInterface|string[]|int[]|float[]|DateTimeInterface[] $value The value to get the models by. + * @param string $operator The operator to use. + * @param int $limit The limit of models to return. + * @param string $order_by The order by clause to use. * * @return mixed[] The models, or an empty array if no models are found. * @@ -690,12 +692,12 @@ public static function get_all_by( string $column, $value, string $operator = '= $database = Config::get_db(); $results = []; - foreach ( static::fetch_all_where( $database::prepare( "WHERE %i {$operator} {$placeholder}", ...array_filter( [ $column, $value ], static fn( $v ) => null !== $v ) ), $limit, ARRAY_A, $order_by ) as $task_array ) { - if ( empty( $task_array[ static::uid_column() ] ) ) { + foreach ( static::fetch_all_where( $database::prepare( "WHERE %i {$operator} {$placeholder}", ...array_filter( [ $column, ...self::ensure_array( $value ) ], static fn( $v ) => null !== $v ) ), $limit, ARRAY_A, $order_by ) as $data_array ) { + if ( empty( $data_array[ static::uid_column() ] ) ) { continue; } - $results[] = static::transform_from_array( self::amend_value_types( $task_array ) ); + $results[] = static::transform_from_array( self::amend_value_types( $data_array ) ); } return $results; @@ -705,25 +707,35 @@ public static function get_all_by( string $column, $value, string $operator = '= * Gets the first model by a column. * * @since 3.0.0 + * @since 3.1.4 Enabled unfolding the value if is an array. + * @since 3.1.4 Added the $operator parameter. * - * @param string $column The column to get the model by. - * @param mixed $value The value to get the model by. + * @param string $column The column to get the model by. + * @param null|int|string|float|bool|DateTimeInterface|string[]|int[]|float[]|DateTimeInterface[] $value The value to get the model by. + * @param string $operator The operator to use. * * @return ?mixed The model, or `null` if no model is found. * * @throws InvalidArgumentException If the column does not exist. + * @throws InvalidArgumentException If the operator is invalid. */ - public static function get_first_by( string $column, $value ) { + public static function get_first_by( string $column, $value, string $operator = '=' ) { [ $value, $placeholder ] = self::prepare_value_for_query( $column, $value ); + $operator = strtoupper( $operator ); + + if ( ! in_array( $operator, self::operators(), true ) ) { + throw new InvalidArgumentException( "Invalid operator: {$operator}." ); + } + $database = Config::get_db(); - $task_array = static::fetch_first_where( $database::prepare( "WHERE %i = {$placeholder}", ...array_filter( [ $column, $value ], static fn( $v ) => null !== $v ) ), ARRAY_A ); + $data_array = static::fetch_first_where( $database::prepare( "WHERE %i {$operator} {$placeholder}", ...array_filter( [ $column, ...self::ensure_array( $value ) ], static fn( $v ) => null !== $v ) ), ARRAY_A ); - if ( empty( $task_array[ static::uid_column() ] ) ) { + if ( empty( $data_array[ static::uid_column() ] ) ) { return null; } - return static::transform_from_array( self::amend_value_types( $task_array ) ); + return static::transform_from_array( self::amend_value_types( $data_array ) ); } /** @@ -731,8 +743,8 @@ public static function get_first_by( string $column, $value ) { * * @since 3.0.0 * - * @param string $column The column to prepare the value for. - * @param mixed $value The value to prepare. + * @param string $column The column to prepare the value for. + * @param null|string|int|float|bool|DateTimeInterface|array $value The value to prepare. * * @return array{0: mixed, 1: string} The prepared value and placeholder. * @@ -791,6 +803,10 @@ private static function prepare_value_for_query( string $column, $value ): array throw new InvalidArgumentException( "Unsupported column type: $column_type." ); } + if ( is_array( $value ) && ! $value ) { + return [ null, '(NULL)' ]; + } + // @phpstan-ignore-next-line return [ $value, is_array( $value ) ? '(' . implode( ',', array_fill( 0, count( $value ), $placeholder ) ) . ')' : $placeholder ]; } @@ -918,4 +934,29 @@ public static function cast_value_based_on_type( string $type, $value ) { throw new InvalidArgumentException( "Unsupported column type: {$type}." ); } } + + /** + * Ensures the value is an array. + * + * @since 3.1.4 + * + * @param null|string|int|float|bool|DateTimeInterface|string[]|int[]|float[]|DateTimeInterface[] $value The value to ensure is an array. + * + * @return array The value as an array. + */ + private static function ensure_array( $value ): array { + if ( null !== $value && ! is_int( $value ) && ! is_string( $value ) && ! is_float( $value ) && ! is_bool( $value ) && ! $value instanceof DateTimeInterface && ! is_array( $value ) ) { + throw new InvalidArgumentException( 'Value should be an integer, string, float, boolean, DateTimeInterface, or array.' ); + } + + if ( is_array( $value ) && $value ) { + foreach ( $value as $k => $v ) { + if ( null !== $v && ! is_int( $v ) && ! is_string( $v ) && ! is_float( $v ) && ! is_bool( $v ) && ! $v instanceof DateTimeInterface ) { + throw new InvalidArgumentException( 'Value with offset ' . $k . ' should be an integer, string, float, boolean or DateTimeInterface.' ); + } + } + } + + return is_array( $value ) ? $value : [ $value ]; + } } diff --git a/tests/wpunit/Traits/Custom_Table_Query_MethodsTest.php b/tests/wpunit/Traits/Custom_Table_Query_MethodsTest.php new file mode 100644 index 0000000..52d4276 --- /dev/null +++ b/tests/wpunit/Traits/Custom_Table_Query_MethodsTest.php @@ -0,0 +1,273 @@ +get_query_test_table()->drop(); + } + + /** + * @test + */ + public function should_update_multiple_with_array_values() { + $table = $this->get_query_test_table(); + Register::table( $table ); + + // Insert test data + $table::insert( [ + 'name' => 'Test 1', + 'slug' => 'test-1', + 'status' => 1, + ] ); + + $id1 = DB::last_insert_id(); + + $table::insert( [ + 'name' => 'Test 2', + 'slug' => 'test-2', + 'status' => 1, + ] ); + + $id2 = DB::last_insert_id(); + + // Update multiple rows using array values + $updated = $table::update_many( [ + [ + 'id' => $id1, + 'name' => 'Updated Test 1', + ], + [ + 'id' => $id2, + 'name' => 'Updated Test 2', + ], + ] ); + + $this->assertEquals( 2, $updated ); + + // Verify the updates + $result1 = $table::get_first_by( 'slug', 'test-1' ); + $this->assertEquals( 'Updated Test 1', $result1['name'] ); + + $result2 = $table::get_first_by( 'slug', 'test-2' ); + $this->assertEquals( 'Updated Test 2', $result2['name'] ); + } + + /** + * @test + */ + public function should_get_all_by_with_array_values() { + $table = $this->get_query_test_table(); + Register::table( $table ); + + // Insert test data + $table::insert( [ + 'name' => 'Test 1', + 'slug' => 'test-1', + 'status' => 1, + ] ); + + $id1 = DB::last_insert_id(); + + $table::insert( [ + 'name' => 'Test 2', + 'slug' => 'test-2', + 'status' => 1, + ] ); + + $id2 = DB::last_insert_id(); + + $table::insert( [ + 'name' => 'Test 3', + 'slug' => 'test-3', + 'status' => 0, + ] ); + + $id3 = DB::last_insert_id(); + + // Get all by status using array (simulating IN operator scenario) + $results = $table::get_all_by( 'status', [ 1, 0 ], 'IN' ); + + $this->assertCount( 3, $results ); + + $this->assertEquals( 'Test 1', $results[0]['name'] ); + $this->assertEquals( 'Test 2', $results[1]['name'] ); + $this->assertEquals( 'Test 3', $results[2]['name'] ); + + $this->assertEquals( 1, $results[0]['status'] ); + $this->assertEquals( 1, $results[1]['status'] ); + $this->assertEquals( 0, $results[2]['status'] ); + } + + /** + * @test + */ + public function should_handle_empty_array() { + $table = $this->get_query_test_table(); + Register::table( $table ); + + // Insert test data + $table::insert( [ + 'name' => 'Test 1', + 'slug' => 'test-1', + 'status' => 1, + ] ); + + $id1 = DB::last_insert_id(); + + $table::insert( [ + 'name' => 'Test 2', + 'slug' => 'test-2', + 'status' => 1, + ] ); + + $id2 = DB::last_insert_id(); + + $table::insert( [ + 'name' => 'Test 3', + 'slug' => 'test-3', + 'status' => 0, + ] ); + + $id3 = DB::last_insert_id(); + + $results = $table::get_all_by( 'status', [], 'IN' ); + + $this->assertEmpty( $results ); + } + + /** + * @test + */ + public function should_get_first_by_with_array_values() { + $table = $this->get_query_test_table(); + Register::table( $table ); + + // Insert test data + $table::insert( [ + 'name' => 'First Match', + 'slug' => 'first-match', + 'status' => 1, + ] ); + + $table::insert( [ + 'name' => 'Second Match', + 'slug' => 'second-match', + 'status' => 1, + ] ); + + // Get first by slug + $result = $table::get_first_by( 'slug', [ 'second-match' ], 'NOT IN' ); + + $this->assertNotNull( $result ); + $this->assertEquals( 'First Match', $result['name'] ); + } + + /** + * @test + */ + public function should_update_multiple_with_integer_array_values() { + $table = $this->get_query_test_table(); + Register::table( $table ); + + // Insert test data + $table::insert( [ + 'name' => 'Active Item', + 'slug' => 'active-item', + 'status' => 1, + ] ); + + $id1 = DB::last_insert_id(); + + // Update using integer value + $updated = $table::update_many( [ + [ + 'id' => $id1, + 'status' => 0, + ], + ] ); + + $this->assertEquals( 1, $updated ); + + // Verify the update + $result = $table::get_first_by( 'slug', 'active-item' ); + $this->assertEquals( 0, $result['status'] ); + } + + /** + * @test + */ + public function should_handle_scalar_values_in_queries() { + $table = $this->get_query_test_table(); + Register::table( $table ); + + // Insert test data with scalar values + $table::insert( [ + 'name' => 'Scalar Test', + 'slug' => 'scalar-test', + 'status' => 1, + ] ); + + $id = DB::last_insert_id(); + + $this->assertIsInt( $id ); + $this->assertGreaterThan( 0, $id ); + + // Verify scalar value retrieval + $result = $table::get_first_by( 'id', $id ); + $this->assertEquals( 'Scalar Test', $result['name'] ); + } + + /** + * Get a test table for query method testing. + */ + private function get_query_test_table() { + return new class extends Table { + const SCHEMA_VERSION = '1.0.0'; + + protected static $base_table_name = 'query_test'; + protected static $group = 'test'; + protected static $schema_slug = 'test-query'; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new ID( 'id' ) )->set_length( 11 )->set_type( Column_Types::INT ); + $columns[] = ( new String_Column( 'name' ) )->set_length( 255 ); + $columns[] = ( new String_Column( 'slug' ) )->set_length( 255 )->set_is_index( true ); + $columns[] = ( new Integer_Column( 'status' ) )->set_length( 1 )->set_default( 0 ); + + return new Table_Schema( $table_name, $columns ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; + } + + public static function transform_from_array( array $result_array ) { + return $result_array; + } + }; + } +}