diff --git a/.github/workflows/tests-php.yml b/.github/workflows/tests-php.yml index 1fb7867..9446707 100644 --- a/.github/workflows/tests-php.yml +++ b/.github/workflows/tests-php.yml @@ -28,13 +28,12 @@ jobs: # Prepare our composer cache directory # ------------------------------------------------------------------------------ - name: Get Composer Cache Directory - id: get-composer-cache-dir - run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" - - uses: actions/cache@v2 id: composer-cache + run: | + echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - uses: actions/cache@v4 with: - path: ${{ steps.get-composer-cache-dir.outputs.dir }} + path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-composer- diff --git a/README.md b/README.md index 696ff70..af7abbf 100644 --- a/README.md +++ b/README.md @@ -874,12 +874,74 @@ DB::table('table_name') The `QueryBuilder::delete` method may be used to delete records from the table. +Unlike WordPress's `$wpdb->delete()` method, this implementation generates and executes a DELETE SQL statement directly, which allows for advanced features like ORDER BY and LIMIT. + +#### Basic delete with WHERE + ```php DB::table('posts') ->where('post_author', 1) ->delete(); ``` +#### Delete with LIMIT + +Limit the number of rows to delete: + +```php +// Delete only the first 10 draft posts +DB::table('posts') + ->where('post_status', 'draft') + ->limit(10) + ->delete(); +``` + +#### Delete with ORDER BY and LIMIT + +Control which rows are deleted when using LIMIT: + +```php +// Delete the 100 oldest posts in trash +DB::table('posts') + ->where('post_status', 'trash') + ->orderBy('post_date', 'ASC') + ->limit(100) + ->delete(); +``` + +#### Delete with LIKE patterns + +Use pattern matching to delete rows: + +```php +// Delete all posts with titles starting with "Draft:" +DB::table('posts') + ->whereLike('post_title', 'Draft:%') + ->delete(); +``` + +#### Delete with complex WHERE conditions + +Combine multiple WHERE clauses for precise deletion: + +```php +// Delete auto-draft pages with IDs between 1 and 1000 +DB::table('posts') + ->where('post_type', 'page') + ->where('post_status', 'auto-draft') + ->whereBetween('ID', 1, 1000) + ->delete(); + +// Delete posts using whereIn +DB::table('posts') + ->whereIn('ID', [5, 10, 15, 20]) + ->delete(); +``` + +**Important restrictions:** +- Table aliases in the FROM clause may not be supported on older database versions (MySQL < 8.0.24, MariaDB < 11.6). Avoid using table aliases when calling `delete()`. +- JOINs are not supported in DELETE statements with this implementation. + ### Get diff --git a/src/DB/QueryBuilder/Concerns/CRUD.php b/src/DB/QueryBuilder/Concerns/CRUD.php index 269ef5f..8775d4b 100644 --- a/src/DB/QueryBuilder/Concerns/CRUD.php +++ b/src/DB/QueryBuilder/Concerns/CRUD.php @@ -75,18 +75,56 @@ public function upsert( $data, $match = [], $format = null ) { } /** + * Delete rows from the database. + * + * Unlike WordPress's $wpdb->delete() method, this implementation generates and executes + * a DELETE SQL statement directly, which allows for advanced features like ORDER BY and LIMIT. + * + * Supports: + * - WHERE clauses (including whereLike, whereIn, whereBetween, etc.) + * - ORDER BY for controlling which rows are deleted first + * - LIMIT to restrict the number of rows deleted + * - Complex WHERE conditions (AND, OR, nested queries) + * + * Usage examples: + * ```php + * // Simple delete with WHERE + * DB::table('posts')->where('post_status', 'draft')->delete(); + * + * // Delete with LIMIT (delete only 10 rows) + * DB::table('posts')->where('post_type', 'temp')->limit(10)->delete(); + * + * // Delete oldest posts first using ORDER BY and LIMIT + * DB::table('posts') + * ->where('post_status', 'trash') + * ->orderBy('post_date', 'ASC') + * ->limit(100) + * ->delete(); + * + * // Delete with LIKE pattern + * DB::table('posts')->whereLike('post_title', 'Draft:%')->delete(); + * + * // Delete with multiple conditions + * DB::table('posts') + * ->where('post_type', 'page') + * ->where('post_status', 'auto-draft') + * ->whereBetween('ID', 1, 1000) + * ->delete(); + * ``` + * + * Restrictions: + * - Table aliases in the FROM clause may not be supported on older database versions + * (MySQL < 8.0.24, MariaDB < 11.6). Avoid using table aliases with delete(). + * - JOINs are not supported in DELETE statements with this implementation + * * @since 1.0.0 * - * @return false|int + * @return false|int Number of rows deleted, or false on error. * - * @see https://developer.wordpress.org/reference/classes/wpdb/delete/ + * @see QueryBuilder::deleteSQL() for the SQL generation logic */ public function delete() { - return DB::delete( - $this->getTable(), - $this->getWhere(), - null - ); + return DB::query( $this->deleteSQL() ); } /** diff --git a/src/DB/QueryBuilder/QueryBuilder.php b/src/DB/QueryBuilder/QueryBuilder.php index 76b8bc2..15d441b 100644 --- a/src/DB/QueryBuilder/QueryBuilder.php +++ b/src/DB/QueryBuilder/QueryBuilder.php @@ -53,7 +53,38 @@ public function getSQL() { $this->getUnionSQL() ); - // Trim double spaces added by DB::prepare + return $this->buildSQL($sql); + } + + /** + * Generate the SQL for a DELETE query. Only the FROM, WHERE, ORDER BY, and LIMIT clauses are included. + * RETURNING is not supported. + * Note that aliases are supported only on MySQL >= 8.0.24 and MariaDB >= 11.6. + * + * @see https://mariadb.com/docs/server/reference/sql-statements/data-manipulation/changing-deleting-data/delete + * @see https://dev.mysql.com/doc/refman/8.4/en/delete.html + * + * @return string DELETE query. + */ + public function deleteSQL() { + $sql = array_merge( + $this->getFromSQL(), + $this->getWhereSQL(), + $this->getOrderBySQL(), + $this->getLimitSQL() + ); + + return 'DELETE ' . $this->buildSQL($sql); + } + + /** + * Build the SQL query from the given parts. + * + * @param array $sql The SQL query parts. + * + * @return string SQL query. + */ + private function buildSQL( $sql ) { return str_replace( [ ' ', ' ' ], ' ', diff --git a/tests/wpunit/QueryBuilder/CRUDTest.php b/tests/wpunit/QueryBuilder/CRUDTest.php index 894e692..a379de5 100644 --- a/tests/wpunit/QueryBuilder/CRUDTest.php +++ b/tests/wpunit/QueryBuilder/CRUDTest.php @@ -214,4 +214,237 @@ public function testUpsertShouldUpdateRowInDatabase() $this->assertEquals($further_updated_data['post_content'], $post->post_content); } + /** + * Tests if delete() can delete with both ORDER BY and LIMIT clauses. + * + * @return void + */ + public function testDeleteShouldWorkWithOrderByAndLimit() + { + // Insert multiple posts + $posts = [ + ['post_title' => 'Delete Combined A', 'post_type' => 'delete_combined_test', 'post_content' => 'Content A'], + ['post_title' => 'Delete Combined B', 'post_type' => 'delete_combined_test', 'post_content' => 'Content B'], + ['post_title' => 'Delete Combined C', 'post_type' => 'delete_combined_test', 'post_content' => 'Content C'], + ['post_title' => 'Delete Combined D', 'post_type' => 'delete_combined_test', 'post_content' => 'Content D'], + ]; + + $ids = $this->insert_posts($posts); + $this->assert_posts_exist($posts, $ids); + + // Delete the 2 oldest posts (lowest IDs) + DB::table('posts') + ->where('post_type', 'delete_combined_test') + ->orderBy('ID', 'ASC') + ->limit(2) + ->delete(); + + $foundPosts = DB::table('posts') + ->select('post_title', 'post_type', 'post_content') + ->whereIn('ID', $ids) + ->getAll(ARRAY_A); + unset($posts[0], $posts[1]); + + $this->assertEquals(array_values($posts), $foundPosts); + } + + /** + * Tests if delete() works with complex WHERE clauses using whereIn. + * + * @return void + */ + public function testDeleteShouldWorkWithWhereIn() + { + // Insert multiple posts + $posts = [ + ['post_title' => 'Delete WhereIn 1', 'post_type' => 'delete_wherein_test', 'post_content' => 'Content 1'], + ['post_title' => 'Delete WhereIn 2', 'post_type' => 'delete_wherein_test', 'post_content' => 'Content 2'], + ['post_title' => 'Delete WhereIn 3', 'post_type' => 'delete_wherein_test', 'post_content' => 'Content 3'], + ['post_title' => 'Delete WhereIn 4', 'post_type' => 'delete_wherein_test', 'post_content' => 'Content 4'], + ]; + + $ids = $this->insert_posts($posts); + $this->assert_posts_exist($posts, $ids); + + // Delete posts with specific IDs using whereIn + DB::table('posts') + ->whereIn('ID', [$ids[0], $ids[2]]) + ->delete(); + + $foundPosts = DB::table('posts') + ->select('post_title', 'post_type', 'post_content') + ->whereIn('ID', $ids) + ->getAll(ARRAY_A); + unset($posts[0], $posts[2]); + + $this->assertEquals(array_values($posts), $foundPosts); + } + + /** + * Tests if delete() works with complex WHERE clauses using whereBetween. + * + * @return void + */ + public function testDeleteShouldWorkWithWhereBetween() + { + // Insert posts with specific menu_order values + $posts = [ + ['post_title' => 'Delete Between 1', 'post_type' => 'delete_between_test', 'menu_order' => 10], + ['post_title' => 'Delete Between 2', 'post_type' => 'delete_between_test', 'menu_order' => 20], + ['post_title' => 'Delete Between 3', 'post_type' => 'delete_between_test', 'menu_order' => 30], + ['post_title' => 'Delete Between 4', 'post_type' => 'delete_between_test', 'menu_order' => 40], + ]; + + $ids = $this->insert_posts($posts); + $this->assert_posts_exist($posts, $ids); + + // Delete posts with menu_order between 15 and 35 + DB::table('posts') + ->where('post_type', 'delete_between_test') + ->whereBetween('menu_order', 15, 35) + ->delete(); + + $foundPosts = DB::table('posts') + ->select('post_title', 'post_type', 'menu_order') + ->whereIn('ID', $ids) + ->getAll(ARRAY_A); + unset($posts[1], $posts[2]); + + $this->assertEquals(array_values($posts), $foundPosts); + } + + /** + * Tests if delete() works with multiple WHERE conditions. + * + * @return void + */ + public function testDeleteShouldWorkWithMultipleWhereConditions() + { + // Insert multiple posts with different types + $posts = [ + ['post_title' => 'Delete Multi 1', 'post_type' => 'type_a', 'post_status' => 'publish'], + ['post_title' => 'Delete Multi 2', 'post_type' => 'type_a', 'post_status' => 'draft'], + ['post_title' => 'Delete Multi 3', 'post_type' => 'type_b', 'post_status' => 'publish'], + ['post_title' => 'Delete Multi 4', 'post_type' => 'type_b', 'post_status' => 'draft'], + ]; + + $ids = $this->insert_posts($posts); + $this->assert_posts_exist($posts, $ids); + + // Delete only posts with type_a AND status publish + DB::table('posts') + ->where('post_type', 'type_a') + ->where('post_status', 'publish') + ->where('post_title', 'Delete Multi 1') + ->delete(); + + $foundPosts = DB::table('posts') + ->select('post_title', 'post_type', 'post_status') + ->whereIn('ID', $ids) + ->getAll(ARRAY_A); + unset($posts[0]); + + $this->assertEquals(array_values($posts), $foundPosts); + } + + /** + * Tests if delete() works with whereLike clause. + * + * @return void + */ + public function testDeleteShouldWorkWithWhereLike() + { + // Insert multiple posts with similar titles + $posts = [ + ['post_title' => 'Product: Widget ABC', 'post_type' => 'delete_like_test', 'post_content' => 'Content 1'], + ['post_title' => 'Product: WidgetXYZ', 'post_type' => 'delete_like_test', 'post_content' => 'Content 2'], + ['post_title' => 'Product: Gadget ABC', 'post_type' => 'delete_like_test', 'post_content' => 'Content 3'], + ['post_title' => 'Service: Widget ABC', 'post_type' => 'delete_like_test', 'post_content' => 'Content 4'], + ]; + + $ids = $this->insert_posts($posts); + $this->assert_posts_exist($posts, $ids); + + // Delete all posts with titles containing "Widget" + DB::table('posts') + ->where('post_type', 'delete_like_test') + ->whereLike('post_title', '%Widget%') + ->delete(); + + $foundPosts = DB::table('posts') + ->select('post_title', 'post_type', 'post_content') + ->whereIn('ID', $ids) + ->getAll(ARRAY_A); + + $this->assertEquals([$posts[2]], $foundPosts); + } + + /** + * Tests if delete() works with whereLike using wildcard prefix. + * + * @return void + */ + public function testDeleteShouldWorkWithWhereLikePrefix() + { + // Insert multiple posts with different prefixes + $posts = [ + ['post_title' => 'Draft: Important Document', 'post_type' => 'delete_prefix_test', 'post_content' => 'Content 1'], + ['post_title' => 'Draft: Meeting Notes', 'post_type' => 'delete_prefix_test', 'post_content' => 'Content 2'], + ['post_title' => 'Final: Important Document Not Draft', 'post_type' => 'delete_prefix_test', 'post_content' => 'Content 3'], + ['post_title' => 'Review: Meeting Notes', 'post_type' => 'delete_prefix_test', 'post_content' => 'Content 4'], + ]; + + $ids = $this->insert_posts($posts); + $this->assert_posts_exist($posts, $ids); + + // Delete all posts starting with "Draft:" + DB::table('posts') + ->where('post_type', 'delete_prefix_test') + ->whereLike('post_title', 'Draft:%') + ->delete(); + + $foundPosts = DB::table('posts') + ->select('post_title', 'post_type', 'post_content') + ->whereIn('ID', $ids) + ->getAll(ARRAY_A); + + $this->assertEquals([$posts[2], $posts[3]], $foundPosts); + } + + /** + * Inserts multiple posts into the database and returns their IDs. + * + * @param array $posts An array of associative arrays, where each associative array represents a post to insert. + * + * @return array An array of IDs corresponding to the inserted posts. + */ + private function insert_posts( array $posts ): array { + $ids = []; + foreach ($posts as $post) { + DB::table('posts')->insert($post); + $ids[] = DB::last_insert_id(); + } + + return $ids; + } + + /** + * Asserts that posts with the given IDs exist and match the specified data. + * + * @param array $posts An array of expected post data to validate against the database. + * @param array $ids An array of post IDs to check for existence in the database. + * + * @return void + */ + private function assert_posts_exist( array $posts, array $ids ) { + $this->assertNotEmpty($posts); + $this->assertNotEmpty($ids); + + $foundPosts = DB::table('posts') + ->select(...array_keys($posts[0])) + ->whereIn('ID', $ids) + ->getAll(ARRAY_A); + + $this->assertEquals($posts, $foundPosts); + } }