Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chunk write #93

Merged
merged 4 commits into from
Jul 30, 2024
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
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/Console/Jobs/CommandInQueueJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/Database/Contracts/ChunkWriteServiceContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace LaraStrict\Database\Contracts;

use Closure;
use Generator;
use Illuminate\Database\Eloquent\Model;
use LaraStrict\Database\Entities\ChunkWriteStateEntity;

interface ChunkWriteServiceContract
{
/**
* @param Closure(): Generator<int, Model> $closure
*/
public function write(Closure $closure): ChunkWriteStateEntity;
}
23 changes: 23 additions & 0 deletions src/Database/Entities/ChunkWriteStateEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace LaraStrict\Database\Entities;

use Illuminate\Database\Eloquent\Model;

class ChunkWriteStateEntity
{
/**
* @param class-string<Model>|null $modelClass
* @param array<array<string, string|int|bool|float>> $toWrite
*/
public function __construct(
public int $batchSize = 0,
public ?string $modelClass = null,
public int $insertedCount = 0,
public int $attributesCount = 0,
public array $toWrite = [],
) {
}
}
82 changes: 82 additions & 0 deletions src/Database/Services/ChunkWriteService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace LaraStrict\Database\Services;

use Closure;
use Illuminate\Database\Eloquent\Model;
use LaraStrict\Database\Contracts\ChunkWriteServiceContract;
use LaraStrict\Database\Entities\ChunkWriteStateEntity;
use LogicException;

final class ChunkWriteService implements ChunkWriteServiceContract
{
public function write(Closure $closure, int $batchSize = 0): ChunkWriteStateEntity
{
$writeState = new ChunkWriteStateEntity(batchSize: $batchSize);

foreach ($closure() as $model) {
$this->add($model, $writeState);
}

$this->finish($writeState);

return $writeState;
}

private function add(Model $model, ChunkWriteStateEntity $state): void
{
if ($model->usesTimestamps()) {
$model->updateTimestamps();
}

/** @var array<string, string|int|bool|float> $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 = [];
}
}
6 changes: 1 addition & 5 deletions src/Testing/Assert/AssertExpectationManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 12 additions & 13 deletions src/Testing/Assert/Traits/AssertExpectationManagerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,31 @@
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();

if ($manager->hasExpectations()) {
$this->addToAssertionCount(1);
$manager->assertCalled();
}

parent::assertPostConditions();
}

protected function tearDown(): void
{
AssertExpectationManager::getInstance()->reset();

parent::tearDown();
}
}
36 changes: 36 additions & 0 deletions src/Tests/Traits/SqlTestEnable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace LaraStrict\Tests\Traits;

use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Database\ConnectionResolver;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\SQLiteConnection;
use PDO;
use PHPUnit\Framework\Assert;

trait SqlTestEnable
{
/**
* @beforeClass
*/
final public static function beforeClassSqlTestEnable(): void
{
$resolver = new ConnectionResolver([
'default' => 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());
}
}
58 changes: 58 additions & 0 deletions tests/Unit/Database/Services/ChunkWriteServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Tests\LaraStrict\Unit\Database\Services;

use Closure;
use LaraStrict\Database\Entities\ChunkWriteStateEntity;
use LaraStrict\Database\Services\ChunkWriteService;
use LaraStrict\Tests\Traits\SqlTestEnable;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;

final class ChunkWriteServiceTest extends TestCase
{
use SqlTestEnable;

/**
* @return array<string|int, array{0: Closure(static):void}>
*/
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);
}
}
15 changes: 15 additions & 0 deletions tests/Unit/Database/Services/TestModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tests\LaraStrict\Unit\Database\Services;

use Illuminate\Database\Eloquent\Model;

final class TestModel extends Model
{
public static function insertOrIgnore(array $data): int
{
return count($data);
}
}
Loading
Loading