Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions UPGRADE-1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,32 @@ public function purge(Query $query): void
```

The built-in `DoctrineDBALJobExecutionStorage` and `FilesystemJobExecutionStorage` already implement this method — no action required if you use either of them.

---

### OpenSpout — `HeaderStrategy` and `SheetFilter` interface changes (BREAKING)

The two interfaces have new, intentional public contracts. The previous `@internal` methods have been removed.

#### `HeaderStrategy`

`setHeaders()` and `getItem()` are replaced by a single method:

```diff
-public function setHeaders(array $headers): bool;
-public function getItem(array $row): array;
+public function process(array $row, bool $isFirstRow): array|null;
```

`process()` receives the raw row and whether it is the first row of the sheet. Return `null` to skip the row (e.g. it is a header row), or return an array to yield it as an item.

#### `SheetFilter`

`list()` is replaced by `accepts()`:

```diff
-public function list(ReaderInterface $reader): Generator;
+public function accepts(SheetInterface $sheet): bool;
```

`accepts()` receives a single sheet and returns whether it should be read.
9 changes: 9 additions & 0 deletions docs/docs/bridges/doctrine-dbal/insert-writer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@

declare(strict_types=1);

use Doctrine\DBAL\Types\Types;
use Doctrine\Persistence\ConnectionRegistry;
use Yokai\Batch\Bridge\Doctrine\DBAL\DoctrineDBALInsertWriter;

/** @var ConnectionRegistry $connectionRegistry */

// Column types are inferred from the table schema automatically
new DoctrineDBALInsertWriter(
doctrine: $connectionRegistry,
table: 'user',
connection: null, // will use default one, but you can pick any registered connection name
);

// Or provide explicit type hints to override auto-detection
new DoctrineDBALInsertWriter(
doctrine: $connectionRegistry,
table: 'user',
types: ['created_at' => Types::DATETIME_IMMUTABLE, 'active' => Types::BOOLEAN],
);
17 changes: 1 addition & 16 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@ parameters:
count: 1
path: src/batch-openspout/src/Reader/FlatFileReader.php

-
message: "#^Parameter \\#1 \\$headers of method Yokai\\\\Batch\\\\Bridge\\\\OpenSpout\\\\Reader\\\\HeaderStrategy\\:\\:setHeaders\\(\\) expects array\\<int, string\\>, array\\<int, bool\\|DateInterval\\|DateTimeInterface\\|float\\|int\\|string\\|null\\> given\\.$#"
count: 1
path: src/batch-openspout/src/Reader/FlatFileReader.php

-
message: "#^Parameter \\#1 \\$options of class OpenSpout\\\\Reader\\\\CSV\\\\Reader constructor expects OpenSpout\\\\Reader\\\\CSV\\\\Options\\|null, OpenSpout\\\\Reader\\\\CSV\\\\Options\\|OpenSpout\\\\Reader\\\\ODS\\\\Options\\|OpenSpout\\\\Reader\\\\XLSX\\\\Options\\|null given\\.$#"
count: 1
Expand All @@ -26,20 +21,10 @@ parameters:
path: src/batch-openspout/src/Reader/FlatFileReader.php

-
message: "#^Parameter \\#1 \\$row of method Yokai\\\\Batch\\\\Bridge\\\\OpenSpout\\\\Reader\\\\HeaderStrategy\\:\\:getItem\\(\\) expects array\\<int, string\\>, array\\<int, bool\\|DateInterval\\|DateTimeInterface\\|float\\|int\\|string\\|null\\> given\\.$#"
message: "#^Parameter \\#1 \\$row of method Yokai\\\\Batch\\\\Bridge\\\\OpenSpout\\\\Reader\\\\HeaderStrategy\\:\\:process\\(\\) expects array\\<int, string\\>, array\\<int, bool\\|DateInterval\\|DateTimeInterface\\|float\\|int\\|string\\|null\\> given\\.$#"
count: 1
path: src/batch-openspout/src/Reader/FlatFileReader.php

-
message: "#^Method Yokai\\\\Batch\\\\Bridge\\\\OpenSpout\\\\Reader\\\\SheetFilter\\:\\:list\\(\\) has parameter \\$reader with generic interface OpenSpout\\\\Reader\\\\ReaderInterface but does not specify its types\\: T$#"
count: 1
path: src/batch-openspout/src/Reader/SheetFilter.php

-
message: "#^Method Yokai\\\\Batch\\\\Bridge\\\\OpenSpout\\\\Reader\\\\SheetFilter\\:\\:list\\(\\) return type with generic interface OpenSpout\\\\Reader\\\\SheetInterface does not specify its types\\: T$#"
count: 1
path: src/batch-openspout/src/Reader/SheetFilter.php

-
message: "#^Parameter \\#1 \\$options of class OpenSpout\\\\Writer\\\\CSV\\\\Writer constructor expects OpenSpout\\\\Writer\\\\CSV\\\\Options\\|null, OpenSpout\\\\Writer\\\\CSV\\\\Options\\|OpenSpout\\\\Writer\\\\ODS\\\\Options\\|OpenSpout\\\\Writer\\\\XLSX\\\\Options\\|null given\\.$#"
count: 1
Expand Down
44 changes: 39 additions & 5 deletions src/batch-doctrine-dbal/src/DoctrineDBALInsertWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Yokai\Batch\Bridge\Doctrine\DBAL;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Type;
use Doctrine\Persistence\ConnectionRegistry;
use Yokai\Batch\Exception\UnexpectedValueException;
use Yokai\Batch\Job\Item\ItemWriterInterface;
Expand All @@ -13,30 +15,62 @@
* This {@see ItemWriterInterface} will insert all items to a single table,
* via a Doctrine {@see Connection}.
* All items must be arrays.
* Column types are inferred from the table schema on first write when not provided explicitly.
*/
final readonly class DoctrineDBALInsertWriter implements ItemWriterInterface
final class DoctrineDBALInsertWriter implements ItemWriterInterface
{
private Connection $connection;
private readonly Connection $connection;

/**
* @var array<int<0,max>, string|ParameterType|Type>|array<string, string|ParameterType|Type>|null
*/
private array|null $types;

/**
* @param array<int<0,max>, string|ParameterType|Type>|array<string, string|ParameterType|Type>|null $types
* Column type hints for DBAL binding. When null, types are resolved lazily on first write
* via table schema introspection. Pass an empty array to disable type resolution entirely.
*/
public function __construct(
ConnectionRegistry $doctrine,
private string $table,
/**
* @var non-empty-string
*/
private readonly string $table,
string|null $connection = null,
array|null $types = null,
) {
$connection ??= $doctrine->getDefaultConnectionName();
/** @var Connection $connection */
$connection = $doctrine->getConnection($connection);
/** @var Connection $connection */
$this->connection = $connection;
$this->types = $types;
}

public function write(iterable $items): void
{
$this->types ??= $this->resolveTypes();

foreach ($items as $item) {
if (!\is_array($item)) {
throw UnexpectedValueException::type('array', $item);
}

$this->connection->insert($this->table, $item);
$this->connection->insert($this->table, $item, $this->types);
}
}

/**
* @return array<string, string|ParameterType|Type>
*/
private function resolveTypes(): array
{
$types = [];
$table = $this->connection->createSchemaManager()->introspectTableByUnquotedName($this->table);
foreach ($table->getColumns() as $column) {
$types[$column->getObjectName()->toString()] = $column->getType();
}

return $types;
}
}
63 changes: 63 additions & 0 deletions src/batch-doctrine-dbal/tests/DoctrineDBALInsertWriterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Yokai\Batch\Tests\Bridge\Doctrine\DBAL;

use Doctrine\DBAL\Schema\Exception\TableDoesNotExist;
use Doctrine\DBAL\Types\Types;
use Yokai\Batch\Bridge\Doctrine\DBAL\DoctrineDBALInsertWriter;
use Yokai\Batch\Exception\UnexpectedValueException;
Expand Down Expand Up @@ -38,10 +39,72 @@ public function test(): void
], $this->findAll('persons'));
}

public function testAutoDetectsColumnTypes(): void
{
$this->createTable('persons', [
'id' => Types::INTEGER,
'firstName' => Types::STRING,
]);

$writer = new DoctrineDBALInsertWriter($this->doctrine, 'persons');

$writer->write([
['id' => 1, 'firstName' => 'John'],
['id' => 2, 'firstName' => 'Jane'],
]);

self::assertSame([
['id' => '1', 'firstName' => 'John'],
['id' => '2', 'firstName' => 'Jane'],
], $this->findAll('persons'));
}

public function testWithExplicitTypes(): void
{
$this->createTable('events', [
'name' => Types::STRING,
'occurred_at' => Types::DATETIME_IMMUTABLE,
'active' => Types::BOOLEAN,
]);

$writer = new DoctrineDBALInsertWriter(
$this->doctrine,
'events',
types: [
'occurred_at' => Types::DATETIME_IMMUTABLE,
'active' => Types::BOOLEAN,
],
);

$writer->write([
['name' => 'signup', 'occurred_at' => new \DateTimeImmutable('2024-01-15 10:00:00'), 'active' => true],
['name' => 'logout', 'occurred_at' => new \DateTimeImmutable('2024-01-16 08:30:00'), 'active' => false],
]);

self::assertSame([
['name' => 'signup', 'occurred_at' => '2024-01-15 10:00:00', 'active' => '1'],
['name' => 'logout', 'occurred_at' => '2024-01-16 08:30:00', 'active' => '0'],
], $this->findAll('events'));
}

public function testItemNotAnArray(): void
{
$this->createTable('persons', [
'firstName' => Types::STRING,
'lastName' => Types::STRING,
]);
$this->expectException(UnexpectedValueException::class);
$writer = new DoctrineDBALInsertWriter($this->doctrine, 'persons');
$writer->write(['string']);
}

public function testTableDoesNotExists(): void
{
$this->expectException(TableDoesNotExist::class);
$writer = new DoctrineDBALInsertWriter($this->doctrine, 'persons');
$writer->write([
['firstName' => 'John', 'lastName' => 'Doe'],
['firstName' => 'Jane', 'lastName' => 'Doe'],
]);
}
}
19 changes: 13 additions & 6 deletions src/batch-openspout/src/Reader/FlatFileReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,8 @@ public function read(): iterable
$reader->open($path);

foreach ($this->rows($reader) as $rowIndex => $row) {
if ($rowIndex === 1 && !$this->headerStrategy->setHeaders($row)) {
continue;
}

try {
yield $this->headerStrategy->getItem($row);
$item = $this->headerStrategy->process($row, $rowIndex === 1);
} catch (InvalidRowSizeException $exception) {
$this->jobExecution->addWarning(
new Warning(
Expand All @@ -78,7 +74,15 @@ public function read(): iterable
['headers' => $exception->getHeaders(), 'row' => $exception->getRow()],
),
);

continue;
}

if ($item === null) {
continue;
}

yield $item;
}

$reader->close();
Expand All @@ -89,7 +93,10 @@ public function read(): iterable
*/
private function rows(ReaderInterface $reader): Generator
{
foreach ($this->sheetFilter->list($reader) as $sheet) {
foreach ($reader->getSheetIterator() as $sheet) {
if (!$this->sheetFilter->accepts($sheet)) {
continue;
}
/** @var int $rowIndex */
/** @var Row $row */
foreach ($sheet->getRowIterator() as $rowIndex => $row) {
Expand Down
43 changes: 20 additions & 23 deletions src/batch-openspout/src/Reader/HeaderStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,35 +56,32 @@ public static function none(array|null $headers = null): self
}

/**
* @param list<string> $headers
* @internal
*/
public function setHeaders(array $headers): bool
{
if ($this->mode === self::NONE) {
return true; // row should be read, will be considered as an item
}
if ($this->mode === self::COMBINE) {
$this->headers = $headers;
}

return false; // row should be skipped, will not be considered as an item
}

/**
* Build the associative item, a combination of headers and values.
*
* @throws InvalidRowSizeException
* Process a row from the file.
* Returns null if the row should be skipped (e.g. it is a header row).
* Returns an array if the row should be yielded as an item.
*
* @param list<string> $row
*
* @return array<string, string>|list<string>
* @internal
* @return array<string, string>|list<string>|null
*
* @throws InvalidRowSizeException
*/
public function getItem(array $row): array
public function process(array $row, bool $isFirstRow): array|null
{
if ($isFirstRow) {
if ($this->mode === self::COMBINE) {
$this->headers = $row;

return null;
}
if ($this->mode === self::SKIP) {
return null;
}
// NONE mode: fall through and treat first row as a regular item
}

if ($this->headers === null) {
return $row; // headers were not set, read row as is
return $row;
}

try {
Expand Down
16 changes: 5 additions & 11 deletions src/batch-openspout/src/Reader/SheetFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
namespace Yokai\Batch\Bridge\OpenSpout\Reader;

use Closure;
use Generator;
use OpenSpout\Reader\ReaderInterface;
use OpenSpout\Reader\RowIteratorInterface;
use OpenSpout\Reader\SheetInterface;

/**
Expand Down Expand Up @@ -54,17 +53,12 @@ public static function nameIs(string $name, string ...$names): self
}

/**
* Iterate over valid sheets for the provided filter.
* Whether the given sheet should be read.
*
* @return Generator<SheetInterface>
* @internal
* @param SheetInterface<RowIteratorInterface> $sheet
*/
public function list(ReaderInterface $reader): Generator
public function accepts(SheetInterface $sheet): bool
{
foreach ($reader->getSheetIterator() as $sheet) {
if (($this->accept)($sheet)) {
yield $sheet;
}
}
return ($this->accept)($sheet);
}
}
Loading
Loading