From 1b3af3b927bd86867fa6f9315e86e2dd9b41b64d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 09:58:43 +0000 Subject: [PATCH 1/6] Initial plan From 7ff5ad52d5561bd6fb5d54c6773eff608fc4e5f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 10:11:08 +0000 Subject: [PATCH 2/6] Add QueryDataWriter implementation with tests and documentation Co-authored-by: samdark <47294+samdark@users.noreply.github.com> --- .phpunit.result.cache | 1 + CHANGELOG.md | 1 + README.md | 55 +++++- src/QueryDataWriter.php | 141 ++++++++++++++ tests/Sqlite/QueryDataWriterTest.php | 280 +++++++++++++++++++++++++++ 5 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 .phpunit.result.cache create mode 100644 src/QueryDataWriter.php create mode 100644 tests/Sqlite/QueryDataWriterTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..8868d45 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":{"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteWithEmptyItems":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteSkipsEmptyItem":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteThrowsExceptionOnMissingPrimaryKey":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteWithCompositeKey":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteWithEmptyItems":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDelete":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteThrowsExceptionOnInvalidItem":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteUpserts":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteThrowsExceptionOnInvalidItem":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteInserts":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteSkipsEmptyItem":4},"times":{"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteWithEmptyItems":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteSkipsEmptyItem":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteThrowsExceptionOnMissingPrimaryKey":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteWithCompositeKey":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteWithEmptyItems":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDelete":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteThrowsExceptionOnInvalidItem":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteUpserts":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteThrowsExceptionOnInvalidItem":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteInserts":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteSkipsEmptyItem":0}} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index add5bb0..2992071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,5 +2,6 @@ ## 1.0.0 under development +- New #55: Add `QueryDataWriter` for writing and deleting data to/from database tables (@copilot) - Initial release. diff --git a/README.md b/README.md index 69e7488..b8f0219 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ [![type-coverage](https://shepherd.dev/github/yiisoft/data-db/coverage.svg)](https://shepherd.dev/github/yiisoft/data-db) [![psalm-level](https://shepherd.dev/github/yiisoft/data-db/level.svg)](https://shepherd.dev/github/yiisoft/data-db) -The package provides [data reader](https://github.com/yiisoft/data?tab=readme-ov-file#reading-data) implementation based -on [Yii DB](https://github.com/yiisoft/db) query and a set of DB-specific filters. +The package provides [data reader](https://github.com/yiisoft/data?tab=readme-ov-file#reading-data) and +[data writer](https://github.com/yiisoft/data?tab=readme-ov-file#writing-data) implementations based +on [Yii DB](https://github.com/yiisoft/db) and a set of DB-specific filters. Detailed build statuses: @@ -119,6 +120,56 @@ foreach ($dataReader->read() as $item) { } ``` +### QueryDataWriter + +The `QueryDataWriter` allows writing (inserting/updating) and deleting data to/from a database table: + +```php +use Yiisoft\Data\Db\QueryDataWriter; + +$writer = new QueryDataWriter($db, 'customer'); + +// Write items (insert or update) +$writer->write([ + ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'], + ['id' => 2, 'name' => 'Jane', 'email' => 'jane@example.com'], +]); + +// Delete items +$writer->delete([ + ['id' => 1], + ['id' => 2], +]); +``` + +By default, `QueryDataWriter` uses UPSERT operations (insert or update). You can configure this behavior: + +```php +// Use plain INSERT instead of UPSERT +$writer = new QueryDataWriter( + db: $db, + table: 'customer', + primaryKey: ['id'], + useUpsert: false +); +``` + +For tables with composite primary keys: + +```php +$writer = new QueryDataWriter( + db: $db, + table: 'order_items', + primaryKey: ['order_id', 'product_id'] +); + +// Delete with composite key +$writer->delete([ + ['order_id' => 1, 'product_id' => 101], + ['order_id' => 1, 'product_id' => 102], +]); +``` + ## Documentation - [Internals](docs/internals.md) diff --git a/src/QueryDataWriter.php b/src/QueryDataWriter.php new file mode 100644 index 0000000..8d71dc9 --- /dev/null +++ b/src/QueryDataWriter.php @@ -0,0 +1,141 @@ + + * + * Example usage: + * + * ```php + * $writer = new QueryDataWriter($db, 'customer'); + * + * // Write (insert or update) items + * $writer->write([ + * ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'], + * ['id' => 2, 'name' => 'Jane', 'email' => 'jane@example.com'], + * ]); + * + * // Delete items + * $writer->delete([ + * ['id' => 1], + * ['id' => 2], + * ]); + * ``` + * + * @psalm-suppress ClassMustBeFinal This class can be extended. + */ +class QueryDataWriter implements DataWriterInterface +{ + /** + * @param ConnectionInterface $db The database connection instance. + * @param string $table The name of the table to write to or delete from. + * @param array $primaryKey The primary key column name(s). Defaults to ['id']. + * @param bool $useUpsert Whether to use UPSERT (insert or update) instead of plain INSERT. + * When true, existing records will be updated, otherwise only new records will be inserted. + * Defaults to true. + */ + public function __construct( + private readonly ConnectionInterface $db, + private readonly string $table, + private readonly array $primaryKey = ['id'], + private readonly bool $useUpsert = true, + ) { + } + + /** + * Write items to the database table. + * + * If `$useUpsert` is true (default), this will insert new records or update existing ones. + * If `$useUpsert` is false, this will only insert new records. + * + * @param iterable $items Items to write. Each item must be an associative array of column => value. + * + * @throws DataWriterException If there is an error while writing items. + */ + public function write(iterable $items): void + { + try { + foreach ($items as $item) { + if (!is_array($item)) { + throw new DataWriterException('Each item must be an array.'); + } + + if (empty($item)) { + continue; + } + + if ($this->useUpsert) { + $this->db->createCommand()->upsert($this->table, $item)->execute(); + } else { + $this->db->createCommand()->insert($this->table, $item)->execute(); + } + } + } catch (Throwable $e) { + throw new DataWriterException( + 'Failed to write items to table "' . $this->table . '": ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } + } + + /** + * Delete items from the database table. + * + * Each item should contain the primary key column(s) used to identify the record to delete. + * + * @param iterable $items Items to delete. Each item must be an associative array containing at least + * the primary key column(s). + * + * @throws DataWriterException If there is an error deleting items. + */ + public function delete(iterable $items): void + { + try { + foreach ($items as $item) { + if (!is_array($item)) { + throw new DataWriterException('Each item must be an array.'); + } + + if (empty($item)) { + continue; + } + + $condition = []; + foreach ($this->primaryKey as $key) { + if (!isset($item[$key])) { + throw new DataWriterException( + 'Item must contain primary key column "' . $key . '" for deletion.', + ); + } + $condition[$key] = $item[$key]; + } + + $this->db->createCommand()->delete($this->table, $condition)->execute(); + } + } catch (DataWriterException $e) { + throw $e; + } catch (Throwable $e) { + throw new DataWriterException( + 'Failed to delete items from table "' . $this->table . '": ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } + } +} diff --git a/tests/Sqlite/QueryDataWriterTest.php b/tests/Sqlite/QueryDataWriterTest.php new file mode 100644 index 0000000..14ee025 --- /dev/null +++ b/tests/Sqlite/QueryDataWriterTest.php @@ -0,0 +1,280 @@ +getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id' => $columnBuilder::integer()->notNull()->primaryKey(), + 'name' => $columnBuilder::text(), + 'email' => $columnBuilder::text(), + ])->execute(); + + $writer = new QueryDataWriter($db, 'test', ['id'], false); // useUpsert = false + + $items = [ + ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'], + ['id' => 2, 'name' => 'Jane', 'email' => 'jane@example.com'], + ]; + + $writer->write($items); + + $result = $db->createQuery()->from('test')->all(); + $this->assertCount(2, $result); + $this->assertSame('1', $result[0]['id']); + $this->assertSame('John', $result[0]['name']); + $this->assertSame('john@example.com', $result[0]['email']); + $this->assertSame('2', $result[1]['id']); + $this->assertSame('Jane', $result[1]['name']); + $this->assertSame('jane@example.com', $result[1]['email']); + } + + public function testWriteUpserts(): void + { + $db = TestHelper::createSqliteConnection(); + $columnBuilder = $db->getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id' => $columnBuilder::integer()->notNull()->primaryKey(), + 'name' => $columnBuilder::text(), + 'email' => $columnBuilder::text(), + ])->execute(); + + // Insert initial data + $db->createCommand()->insert('test', ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'])->execute(); + + $writer = new QueryDataWriter($db, 'test', ['id'], true); // useUpsert = true + + $items = [ + ['id' => 1, 'name' => 'John Updated', 'email' => 'john.updated@example.com'], // Update + ['id' => 2, 'name' => 'Jane', 'email' => 'jane@example.com'], // Insert + ]; + + $writer->write($items); + + $result = $db->createQuery()->from('test')->all(); + $this->assertCount(2, $result); + $this->assertSame('1', $result[0]['id']); + $this->assertSame('John Updated', $result[0]['name']); + $this->assertSame('john.updated@example.com', $result[0]['email']); + $this->assertSame('2', $result[1]['id']); + $this->assertSame('Jane', $result[1]['name']); + $this->assertSame('jane@example.com', $result[1]['email']); + } + + public function testWriteWithEmptyItems(): void + { + $db = TestHelper::createSqliteConnection(); + $columnBuilder = $db->getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id' => $columnBuilder::integer()->notNull()->primaryKey(), + 'name' => $columnBuilder::text(), + ])->execute(); + + $writer = new QueryDataWriter($db, 'test'); + + $writer->write([]); + + $result = $db->createQuery()->from('test')->all(); + $this->assertCount(0, $result); + } + + public function testWriteSkipsEmptyItem(): void + { + $db = TestHelper::createSqliteConnection(); + $columnBuilder = $db->getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id' => $columnBuilder::integer()->notNull()->primaryKey(), + 'name' => $columnBuilder::text(), + ])->execute(); + + $writer = new QueryDataWriter($db, 'test'); + + $items = [ + ['id' => 1, 'name' => 'John'], + [], // This should be skipped + ['id' => 2, 'name' => 'Jane'], + ]; + + $writer->write($items); + + $result = $db->createQuery()->from('test')->all(); + $this->assertCount(2, $result); + $this->assertSame('1', $result[0]['id']); + $this->assertSame('2', $result[1]['id']); + } + + public function testWriteThrowsExceptionOnInvalidItem(): void + { + $db = TestHelper::createSqliteConnection(); + $columnBuilder = $db->getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id' => $columnBuilder::integer()->notNull()->primaryKey(), + ])->execute(); + + $writer = new QueryDataWriter($db, 'test'); + + $this->expectException(DataWriterException::class); + $this->expectExceptionMessage('Each item must be an array.'); + + $writer->write(['not an array']); + } + + public function testDelete(): void + { + $db = TestHelper::createSqliteConnection(); + $columnBuilder = $db->getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id' => $columnBuilder::integer()->notNull()->primaryKey(), + 'name' => $columnBuilder::text(), + ])->execute(); + + $data = [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ['id' => 3, 'name' => 'Bob'], + ]; + $db->createCommand()->insertBatch('test', $data)->execute(); + + $writer = new QueryDataWriter($db, 'test'); + + $itemsToDelete = [ + ['id' => 1], + ['id' => 3], + ]; + + $writer->delete($itemsToDelete); + + $result = $db->createQuery()->from('test')->all(); + $this->assertCount(1, $result); + $this->assertSame('2', $result[0]['id']); + $this->assertSame('Jane', $result[0]['name']); + } + + public function testDeleteWithCompositeKey(): void + { + $db = TestHelper::createSqliteConnection(); + $columnBuilder = $db->getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id1' => $columnBuilder::integer()->notNull(), + 'id2' => $columnBuilder::integer()->notNull(), + 'name' => $columnBuilder::text(), + ])->execute(); + $db->createCommand()->addPrimaryKey('test', 'pk', ['id1', 'id2'])->execute(); + + $data = [ + ['id1' => 1, 'id2' => 1, 'name' => 'John'], + ['id1' => 1, 'id2' => 2, 'name' => 'Jane'], + ['id1' => 2, 'id2' => 1, 'name' => 'Bob'], + ]; + $db->createCommand()->insertBatch('test', $data)->execute(); + + $writer = new QueryDataWriter($db, 'test', ['id1', 'id2']); + + $itemsToDelete = [ + ['id1' => 1, 'id2' => 1], + ['id1' => 2, 'id2' => 1], + ]; + + $writer->delete($itemsToDelete); + + $result = $db->createQuery()->from('test')->all(); + $this->assertCount(1, $result); + $this->assertSame('1', $result[0]['id1']); + $this->assertSame('2', $result[0]['id2']); + $this->assertSame('Jane', $result[0]['name']); + } + + public function testDeleteWithEmptyItems(): void + { + $db = TestHelper::createSqliteConnection(); + $columnBuilder = $db->getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id' => $columnBuilder::integer()->notNull()->primaryKey(), + 'name' => $columnBuilder::text(), + ])->execute(); + + $db->createCommand()->insert('test', ['id' => 1, 'name' => 'John'])->execute(); + + $writer = new QueryDataWriter($db, 'test'); + + $writer->delete([]); + + $result = $db->createQuery()->from('test')->all(); + $this->assertCount(1, $result); + } + + public function testDeleteSkipsEmptyItem(): void + { + $db = TestHelper::createSqliteConnection(); + $columnBuilder = $db->getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id' => $columnBuilder::integer()->notNull()->primaryKey(), + 'name' => $columnBuilder::text(), + ])->execute(); + + $data = [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ]; + $db->createCommand()->insertBatch('test', $data)->execute(); + + $writer = new QueryDataWriter($db, 'test'); + + $itemsToDelete = [ + ['id' => 1], + [], // This should be skipped + ]; + + $writer->delete($itemsToDelete); + + $result = $db->createQuery()->from('test')->all(); + $this->assertCount(1, $result); + $this->assertSame('2', $result[0]['id']); + } + + public function testDeleteThrowsExceptionOnInvalidItem(): void + { + $db = TestHelper::createSqliteConnection(); + $columnBuilder = $db->getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id' => $columnBuilder::integer()->notNull()->primaryKey(), + ])->execute(); + + $writer = new QueryDataWriter($db, 'test'); + + $this->expectException(DataWriterException::class); + $this->expectExceptionMessage('Each item must be an array.'); + + $writer->delete(['not an array']); + } + + public function testDeleteThrowsExceptionOnMissingPrimaryKey(): void + { + $db = TestHelper::createSqliteConnection(); + $columnBuilder = $db->getColumnBuilderClass(); + $db->createCommand()->createTable('test', [ + 'id' => $columnBuilder::integer()->notNull()->primaryKey(), + 'name' => $columnBuilder::text(), + ])->execute(); + + $db->createCommand()->insert('test', ['id' => 1, 'name' => 'John'])->execute(); + + $writer = new QueryDataWriter($db, 'test'); + + $this->expectException(DataWriterException::class); + $this->expectExceptionMessage('Item must contain primary key column "id" for deletion.'); + + $writer->delete([['name' => 'John']]); + } +} From b263f157bcf28703e7484a433ef487e51661dd96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 10:13:33 +0000 Subject: [PATCH 3/6] Update .gitignore to exclude .phpunit.result.cache Co-authored-by: samdark <47294+samdark@users.noreply.github.com> --- .gitignore | 1 + .phpunit.result.cache | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .phpunit.result.cache diff --git a/.gitignore b/.gitignore index f1fb048..4997099 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ composer.phar /phpunit.phar /phpunit.xml /.phpunit.cache +/.phpunit.result.cache # PHP CS Fixer /.php-cs-fixer.cache diff --git a/.phpunit.result.cache b/.phpunit.result.cache deleted file mode 100644 index 8868d45..0000000 --- a/.phpunit.result.cache +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"defects":{"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteWithEmptyItems":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteSkipsEmptyItem":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteThrowsExceptionOnMissingPrimaryKey":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteWithCompositeKey":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteWithEmptyItems":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDelete":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteThrowsExceptionOnInvalidItem":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteUpserts":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteThrowsExceptionOnInvalidItem":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteInserts":4,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteSkipsEmptyItem":4},"times":{"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteWithEmptyItems":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteSkipsEmptyItem":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteThrowsExceptionOnMissingPrimaryKey":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteWithCompositeKey":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteWithEmptyItems":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDelete":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteThrowsExceptionOnInvalidItem":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteUpserts":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteThrowsExceptionOnInvalidItem":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testWriteInserts":0,"Yiisoft\\Data\\Db\\Tests\\Sqlite\\QueryDataWriterTest::testDeleteSkipsEmptyItem":0}} \ No newline at end of file From eff00fd75f4cc3fc26971e3ec34e17b7a574d8cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 10:14:25 +0000 Subject: [PATCH 4/6] Add comprehensive usage guide for QueryDataReader and QueryDataWriter Co-authored-by: samdark <47294+samdark@users.noreply.github.com> --- README.md | 1 + docs/guide.md | 246 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 docs/guide.md diff --git a/README.md b/README.md index b8f0219..ea2c588 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ $writer->delete([ ## Documentation +- [Usage Guide](docs/guide.md) - [Internals](docs/internals.md) If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..f78a8a6 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,246 @@ +# Usage Guide + +## Reading Data + +The `QueryDataReader` provides a flexible interface for reading data from database tables with support for filtering, +sorting, pagination, and batch processing. + +### Basic Usage + +```php +use Yiisoft\Data\Db\QueryDataReader; +use Yiisoft\Db\Query\Query; + +$query = (new Query($db))->from('customer'); +$dataReader = new QueryDataReader($query); + +// Iterate through results +foreach ($dataReader->read() as $customer) { + // Process each customer +} + +// Read a single record +$customer = $dataReader->readOne(); + +// Get total count +$total = $dataReader->count(); +``` + +### Filtering + +```php +use Yiisoft\Data\Reader\Filter\Equals; +use Yiisoft\Data\Reader\Filter\GreaterThan; +use Yiisoft\Data\Reader\Filter\Like; +use Yiisoft\Data\Reader\Filter\AndX; + +$filter = new AndX( + new Equals('status', 'active'), + new GreaterThan('age', 18), + new Like('name', 'John') +); + +$dataReader = $dataReader->withFilter($filter); +``` + +### Sorting + +```php +use Yiisoft\Data\Reader\Sort; + +$sort = Sort::any(['name', 'email'])->withOrderString('-name,email'); +$dataReader = $dataReader->withSort($sort); +``` + +### Pagination + +```php +$dataReader = $dataReader + ->withOffset(20) + ->withLimit(10); +``` + +### Field Mapping + +Map data reader field names to database columns: + +```php +$dataReader = new QueryDataReader( + query: $query, + fieldMapper: [ + 'userName' => 'user_name', + 'createdAt' => 'created_at', + ] +); + +// Now you can filter and sort by 'userName' and it will use 'user_name' column +$filter = new Equals('userName', 'admin'); +``` + +### Batch Processing + +Process large datasets in batches to reduce memory usage: + +```php +$dataReader = new QueryDataReader($query); +$dataReader = $dataReader->withBatchSize(100); + +foreach ($dataReader->read() as $item) { + // Items are fetched in batches of 100 +} +``` + +## Writing Data + +The `QueryDataWriter` allows writing (inserting/updating) and deleting data to/from database tables. + +### Basic Usage + +```php +use Yiisoft\Data\Db\QueryDataWriter; + +$writer = new QueryDataWriter($db, 'customer'); + +// Write items (insert or update by default) +$writer->write([ + ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'], + ['id' => 2, 'name' => 'Jane', 'email' => 'jane@example.com'], +]); + +// Delete items +$writer->delete([ + ['id' => 1], + ['id' => 2], +]); +``` + +### Insert vs Upsert + +By default, `QueryDataWriter` uses UPSERT operations (insert or update existing records). You can configure this: + +```php +// Use plain INSERT instead of UPSERT +$writer = new QueryDataWriter( + db: $db, + table: 'customer', + primaryKey: ['id'], + useUpsert: false // Will throw exception if record already exists +); +``` + +### Composite Primary Keys + +For tables with multiple primary key columns: + +```php +$writer = new QueryDataWriter( + db: $db, + table: 'order_items', + primaryKey: ['order_id', 'product_id'] +); + +// Delete requires all primary key columns +$writer->delete([ + ['order_id' => 1, 'product_id' => 101], + ['order_id' => 1, 'product_id' => 102], +]); +``` + +### Error Handling + +The writer throws `DataWriterException` on errors: + +```php +use Yiisoft\Data\Writer\DataWriterException; + +try { + $writer->write([ + ['id' => 1, 'name' => 'John'], + ]); +} catch (DataWriterException $e) { + // Handle write error + echo "Failed to write: " . $e->getMessage(); +} +``` + +### Validation + +Each item must be an associative array: + +```php +// ✓ Valid +$writer->write([ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], +]); + +// ✗ Invalid - will throw DataWriterException +$writer->write(['string value']); +``` + +For delete operations, items must contain all primary key columns: + +```php +// ✓ Valid +$writer->delete([ + ['id' => 1], +]); + +// ✗ Invalid - missing primary key +$writer->delete([ + ['name' => 'John'], // Throws: "Item must contain primary key column 'id'" +]); +``` + +### Batch Operations + +Both `write()` and `delete()` accept iterables, allowing you to process large datasets efficiently: + +```php +// Generator for memory-efficient processing +function getCustomers(): Generator { + // Fetch from another source or generate dynamically + for ($i = 1; $i <= 1000; $i++) { + yield ['id' => $i, 'name' => "Customer $i"]; + } +} + +$writer->write(getCustomers()); +``` + +## Combining Reader and Writer + +You can combine reader and writer for data transformation: + +```php +use Yiisoft\Data\Db\QueryDataReader; +use Yiisoft\Data\Db\QueryDataWriter; +use Yiisoft\Data\Reader\Filter\Equals; + +// Read from source table +$sourceQuery = (new Query($db))->from('source_table'); +$reader = new QueryDataReader($sourceQuery); +$reader = $reader->withFilter(new Equals('status', 'pending')); + +// Write to destination table +$writer = new QueryDataWriter($db, 'destination_table'); + +// Transform and write +$items = []; +foreach ($reader->read() as $item) { + $items[] = [ + 'id' => $item['id'], + 'name' => strtoupper($item['name']), + 'processed_at' => time(), + ]; +} +$writer->write($items); + +// Mark as processed in source +$deleteItems = []; +foreach ($reader->read() as $item) { + $deleteItems[] = ['id' => $item['id']]; +} +$sourceWriter = new QueryDataWriter($db, 'source_table'); +$sourceWriter->delete($deleteItems); +``` From 52a05664167e95ffd2a11250104acfeb047655d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 10:15:58 +0000 Subject: [PATCH 5/6] Refactor exception handling to avoid unnecessary catch-and-rethrow Co-authored-by: samdark <47294+samdark@users.noreply.github.com> --- src/QueryDataWriter.php | 74 ++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/src/QueryDataWriter.php b/src/QueryDataWriter.php index 8d71dc9..eea33f7 100644 --- a/src/QueryDataWriter.php +++ b/src/QueryDataWriter.php @@ -69,28 +69,28 @@ public function __construct( */ public function write(iterable $items): void { - try { - foreach ($items as $item) { - if (!is_array($item)) { - throw new DataWriterException('Each item must be an array.'); - } + foreach ($items as $item) { + if (!is_array($item)) { + throw new DataWriterException('Each item must be an array.'); + } - if (empty($item)) { - continue; - } + if (empty($item)) { + continue; + } + try { if ($this->useUpsert) { $this->db->createCommand()->upsert($this->table, $item)->execute(); } else { $this->db->createCommand()->insert($this->table, $item)->execute(); } + } catch (Throwable $e) { + throw new DataWriterException( + 'Failed to write items to table "' . $this->table . '": ' . $e->getMessage(), + $e->getCode(), + $e, + ); } - } catch (Throwable $e) { - throw new DataWriterException( - 'Failed to write items to table "' . $this->table . '": ' . $e->getMessage(), - $e->getCode(), - $e, - ); } } @@ -106,36 +106,34 @@ public function write(iterable $items): void */ public function delete(iterable $items): void { - try { - foreach ($items as $item) { - if (!is_array($item)) { - throw new DataWriterException('Each item must be an array.'); - } + foreach ($items as $item) { + if (!is_array($item)) { + throw new DataWriterException('Each item must be an array.'); + } - if (empty($item)) { - continue; - } + if (empty($item)) { + continue; + } - $condition = []; - foreach ($this->primaryKey as $key) { - if (!isset($item[$key])) { - throw new DataWriterException( - 'Item must contain primary key column "' . $key . '" for deletion.', - ); - } - $condition[$key] = $item[$key]; + $condition = []; + foreach ($this->primaryKey as $key) { + if (!isset($item[$key])) { + throw new DataWriterException( + 'Item must contain primary key column "' . $key . '" for deletion.', + ); } + $condition[$key] = $item[$key]; + } + try { $this->db->createCommand()->delete($this->table, $condition)->execute(); + } catch (Throwable $e) { + throw new DataWriterException( + 'Failed to delete items from table "' . $this->table . '": ' . $e->getMessage(), + $e->getCode(), + $e, + ); } - } catch (DataWriterException $e) { - throw $e; - } catch (Throwable $e) { - throw new DataWriterException( - 'Failed to delete items from table "' . $this->table . '": ' . $e->getMessage(), - $e->getCode(), - $e, - ); } } } From f15635f93ccab20df3ebad6fa3f4e8ae453400be Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 10:20:23 +0000 Subject: [PATCH 6/6] Apply PHP CS Fixer and Rector changes (CI) --- src/QueryDataWriter.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/QueryDataWriter.php b/src/QueryDataWriter.php index eea33f7..3b12fe3 100644 --- a/src/QueryDataWriter.php +++ b/src/QueryDataWriter.php @@ -54,8 +54,7 @@ public function __construct( private readonly string $table, private readonly array $primaryKey = ['id'], private readonly bool $useUpsert = true, - ) { - } + ) {} /** * Write items to the database table.