diff --git a/composer.json b/composer.json index d7cee153..10d57321 100644 --- a/composer.json +++ b/composer.json @@ -13,11 +13,14 @@ ], "require": { "php": ">=8.1", + "h4kuna/serialize-polyfill": "^0.2.4", "laravel/framework": "^9", "psr/log": "^2 | ^3", "psr/simple-cache": "^3.0" }, "require-dev": { + "ext-pdo": "*", + "ext-sqlite3": "*", "mockery/mockery": "^1.5.0", "nette/php-generator": "v4.0.5", "nikic/php-parser": "v4.15.2", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 882c4abb..e81569a1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -25,6 +25,23 @@ parameters: count: 1 path: src/Testing/Actions/ParsePhpDocAction.php + # forward compatibility + - + message: "#^Attribute class PHPUnit\\\\Framework\\\\Attributes\\\\Before does not exist\\.$#" + count: 1 + path: src/Testing/Assert/AssertExpectationTestCase.php + + - + message: "#^Attribute class PHPUnit\\\\Framework\\\\Attributes\\\\PostCondition does not exist\\.$#" + count: 1 + path: src/Testing/Assert/AssertExpectationTestCase.php + + - + message: "#^Method LaraStrict\\\\Testing\\\\Assert\\\\AssertExpectationTestCase\\:\\:beforeStartAssertExpectationManager\\(\\) has no return type specified\\.$#" + count: 1 + path: src/Testing/Assert/AssertExpectationTestCase.php + # forward compatibility + - message: "#^Call to method PHPUnit\\\\Framework\\\\Assert\\:\\:assertTrue\\(\\) with false and 'Hook should be…' will always evaluate to false\\.$#" count: 1 diff --git a/src/Console/Jobs/CommandInQueueJob.php b/src/Console/Jobs/CommandInQueueJob.php index 7c0b24d5..f561cc9e 100644 --- a/src/Console/Jobs/CommandInQueueJob.php +++ b/src/Console/Jobs/CommandInQueueJob.php @@ -4,6 +4,7 @@ namespace LaraStrict\Console\Jobs; +use h4kuna\Serialize\Serialize; use Illuminate\Contracts\Console\Kernel; use Illuminate\Contracts\Queue\ShouldQueue; use Psr\Log\LoggerInterface; @@ -31,7 +32,7 @@ public function __construct( } ksort($parameters); - $this->parametersKey = md5(serialize($parameters)); + $this->parametersKey = md5(Serialize::encode($parameters)); } public function handle(Kernel $kernel, ConsoleOutput $consoleOutput, LoggerInterface $logger): void diff --git a/src/Database/Contracts/ChunkWriteServiceContract.php b/src/Database/Contracts/ChunkWriteServiceContract.php new file mode 100644 index 00000000..48106823 --- /dev/null +++ b/src/Database/Contracts/ChunkWriteServiceContract.php @@ -0,0 +1,18 @@ + $closure + */ + public function write(Closure $closure): ChunkWriteStateEntity; +} diff --git a/src/Database/Entities/ChunkWriteStateEntity.php b/src/Database/Entities/ChunkWriteStateEntity.php new file mode 100644 index 00000000..cf51faf7 --- /dev/null +++ b/src/Database/Entities/ChunkWriteStateEntity.php @@ -0,0 +1,23 @@ +|null $modelClass + * @param array> $toWrite + */ + public function __construct( + public int $batchSize = 0, + public ?string $modelClass = null, + public int $insertedCount = 0, + public int $attributesCount = 0, + public array $toWrite = [], + ) { + } +} diff --git a/src/Database/Services/ChunkWriteService.php b/src/Database/Services/ChunkWriteService.php new file mode 100644 index 00000000..ec2aa3ca --- /dev/null +++ b/src/Database/Services/ChunkWriteService.php @@ -0,0 +1,82 @@ +add($model, $writeState); + } + + $this->finish($writeState); + + return $writeState; + } + + private function add(Model $model, ChunkWriteStateEntity $state): void + { + if ($model->usesTimestamps()) { + $model->updateTimestamps(); + } + + /** @var array $attributes */ + $attributes = $model->getAttributes(); + $attributesCount = count($attributes); + + $modelClass = $model::class; + + if ($state->modelClass === null) { + $state->modelClass = $modelClass; + } elseif ($state->modelClass !== $modelClass) { + throw new LogicException(sprintf( + 'Batch insert must contain items with same class <%s> got <%s>', + $state->modelClass, + $modelClass + )); + } + + // We need to prevent insert max statements by limiting number of insert + if ($state->batchSize === 0) { + $state->batchSize = (int) (65536 / $attributesCount); + } + + if ($state->attributesCount !== 0 && $state->attributesCount !== $attributesCount) { + throw new LogicException('Batch insert must contain items with same attributes count' . print_r( + $attributes, + true + )); + } + + $state->toWrite[] = $attributes; + $state->attributesCount = $attributesCount; + + if ($state->batchSize === count($state->toWrite)) { + $this->finish($state); + } + } + + private function finish(ChunkWriteStateEntity $state): void + { + if ($state->toWrite === [] || $state->modelClass === null) { + return; + } + + // Do not fail on duplicated entries. + $count = $state->modelClass::insertOrIgnore($state->toWrite); + + $state->insertedCount += $count; + $state->toWrite = []; + } +} diff --git a/src/Testing/Assert/AssertExpectationManager.php b/src/Testing/Assert/AssertExpectationManager.php index f6555a04..2f34ad9a 100644 --- a/src/Testing/Assert/AssertExpectationManager.php +++ b/src/Testing/Assert/AssertExpectationManager.php @@ -18,11 +18,7 @@ final class AssertExpectationManager public static function getInstance(): self { - if (self::$singleton === null) { - self::$singleton = new self(); - } - - return self::$singleton; + return self::$singleton ??= new self(); } public static function resetSingleton(): void diff --git a/src/Testing/Assert/Traits/AssertExpectationManagerTrait.php b/src/Testing/Assert/Traits/AssertExpectationManagerTrait.php index 8227aa46..7c3579f8 100644 --- a/src/Testing/Assert/Traits/AssertExpectationManagerTrait.php +++ b/src/Testing/Assert/Traits/AssertExpectationManagerTrait.php @@ -5,17 +5,25 @@ namespace LaraStrict\Testing\Assert\Traits; use LaraStrict\Testing\Assert\AssertExpectationManager; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\PostCondition; trait AssertExpectationManagerTrait { - protected function setUp(): void + /** + * @before + */ + #[Before] + protected function beforeStartAssertExpectationManager() { - parent::setUp(); - AssertExpectationManager::getInstance()->reset(); } - protected function assertPostConditions(): void + /** + * @postCondition + */ + #[PostCondition] + protected function postConditionStartAssertExpectationManager(): void { $manager = AssertExpectationManager::getInstance(); @@ -23,14 +31,5 @@ protected function assertPostConditions(): void $this->addToAssertionCount(1); $manager->assertCalled(); } - - parent::assertPostConditions(); - } - - protected function tearDown(): void - { - AssertExpectationManager::getInstance()->reset(); - - parent::tearDown(); } } diff --git a/src/Tests/Traits/SqlTestEnable.php b/src/Tests/Traits/SqlTestEnable.php new file mode 100644 index 00000000..c4fae28e --- /dev/null +++ b/src/Tests/Traits/SqlTestEnable.php @@ -0,0 +1,36 @@ + new SQLiteConnection(new PDO('sqlite::memory:')), + ]); + $resolver->setDefaultConnection('default'); + Model::setConnectionResolver($resolver); + } + + final protected static function assertQuerySql( + string $expectedSql, + array $expectedBindings, + Builder $query, + ): void { + Assert::assertSame(trim($expectedSql), $query->toSql()); + Assert::assertSame($expectedBindings, $query->getBindings()); + } +} diff --git a/tests/Unit/Database/Services/ChunkWriteServiceTest.php b/tests/Unit/Database/Services/ChunkWriteServiceTest.php new file mode 100644 index 00000000..c8e4442a --- /dev/null +++ b/tests/Unit/Database/Services/ChunkWriteServiceTest.php @@ -0,0 +1,58 @@ + + */ + public static function data(): array + { + return [ + [ + 'empty' => static function (self $self) { + $self->assert(new ChunkWriteStateEntity(), static function () { + yield from []; + },); + }, + ], + [ + static function (self $self) { + $self->assert( + new ChunkWriteStateEntity(32768, TestModel::class, 3, 2), + static function () { + yield from [new TestModel(), new TestModel(), new TestModel()]; + }, + ); + }, + ], + ]; + } + + /** + * @param Closure(static):void $assert + * @dataProvider data + */ + public function test(Closure $assert): void + { + $assert($this); + } + + public function assert(ChunkWriteStateEntity $expected, Closure $data): void + { + $state = (new ChunkWriteService())->write($data); + Assert::assertEquals($expected, $state); + } +} diff --git a/tests/Unit/Database/Services/TestModel.php b/tests/Unit/Database/Services/TestModel.php new file mode 100644 index 00000000..79ea47be --- /dev/null +++ b/tests/Unit/Database/Services/TestModel.php @@ -0,0 +1,15 @@ +expectManagerExceptionMessage === null) { - parent::assertPostConditions(); - return; - } - - // Wrap parent call to - try { - parent::assertPostConditions(); - } catch (Throwable $throwable) { - $this->assertEquals($this->expectManagerExceptionMessage, $throwable->getMessage()); - } - } - public function testSupportsNullableArray(): void { $this->expectExceptionMessage( - 'Expectation for [Tests\LaraStrict\Unit\Testing\TestExpectationCallMap@execute] not set for a n (3) call' + 'Expectation for [Tests\LaraStrict\Unit\Testing\TestExpectationCallMap@execute] not set for a n (3) call', ); $testExpectation1 = new TestExpectation(0); @@ -118,6 +104,25 @@ public function testSetExpectationsFiltered(): void $map->execute($testExpectation1); } + /** + * aaa prefix is for sort hook above + * + * @postCondition + */ + protected function aaaPostConditions(): void + { + if ($this->expectManagerExceptionMessage === null) { + return; + } + + try { + AssertExpectationManager::getInstance()->assertCalled(); + } catch (AssertionFailedError $assertionFailedError) { + $this->assertEquals($this->expectManagerExceptionMessage, $assertionFailedError->getMessage()); + } + AssertExpectationManager::getInstance()->reset(); + } + protected function expectCalledTwice(): void { $this->expectManagerExceptionMessage = '[Tests\LaraStrict\Unit\Testing\TestExpectation] expected 2 call/s but was called <1> time/s';