Skip to content

Commit 33328e3

Browse files
committed
initial version
1 parent f257c83 commit 33328e3

26 files changed

+1013
-36
lines changed

.github/workflows/run-tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
fail-fast: true
1818
matrix:
1919
os: [ubuntu-latest, windows-latest]
20-
php: [8.3, 8.2, 8.1]
20+
php: [8.3, 8.2]
2121
laravel: [10.*]
2222
stability: [prefer-lowest, prefer-stable]
2323
include:

README.md

+31-19
Original file line numberDiff line numberDiff line change
@@ -15,39 +15,51 @@ You can install the package via composer:
1515
composer require rokde/laravel-clone-database-command
1616
```
1717

18-
You can publish and run the migrations with:
18+
## Usage
19+
20+
You can use the pre-configured artisan console command:
1921

2022
```bash
21-
php artisan vendor:publish --tag="laravel-clone-database-command-migrations"
22-
php artisan migrate
23+
php artisan db:clone
2324
```
2425

25-
You can publish the config file with:
26+
This assumes that there are the entries `source` and `target` in the database configuration.
2627

27-
```bash
28-
php artisan vendor:publish --tag="laravel-clone-database-command-config"
29-
```
28+
Otherwise you can create your own clone command to meet the configurable needs.
3029

31-
This is the contents of the published config file:
30+
All single tasks can be found in the `src/Actions` folder. So you can join it like you want if necessary.
3231

33-
```php
34-
return [
35-
];
36-
```
32+
### Configuration
3733

38-
Optionally, you can publish the views using
34+
The whole configuration is stored in a class `DatabaseSyncConfiguration`.
3935

40-
```bash
41-
php artisan vendor:publish --tag="laravel-clone-database-command-views"
42-
```
36+
#### source & target connection
4337

44-
## Usage
38+
The name of the connections and the connection configuration is editable. So if you already have a `target` or `source` connection configured - you can change that name if necessary.
39+
40+
#### chunk size
41+
42+
The chunk size can be configured for a specific table or for any table.
43+
44+
#### limit
45+
46+
The limit of rows can be configured for a specific table or for any table.
47+
48+
#### mutations
49+
50+
A mutation can be configured for a specific table or for any table. So the given column name can be used for any table when existent. So you can replace all `email` columns by a fake email like so:
4551

4652
```php
47-
$laravelCloneDatabaseCommand = new Rokde\LaravelCloneDatabaseCommand();
48-
echo $laravelCloneDatabaseCommand->echoPhrase('Hello, Rokde!');
53+
$config->addMutation('email', fn() => fake()->email);
4954
```
5055

56+
#### behaviour
57+
58+
We can decided what to do with the tables already existing on the target: keep it as is, or drop all unhandled tables.
59+
60+
Another option is to delete records before inserting the new ones or should the table be dropped before and the structure should be stored newly.
61+
62+
5163
## Testing
5264

5365
```bash

composer.json

+13-10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rokde/laravel-clone-database-command",
3-
"description": "This is my package laravel-clone-database-command",
3+
"description": "This package adds the ability to clone a database for development purposes locally. So you can overwrite personal data with faker values to keep the data type.",
44
"keywords": [
55
"laravel",
66
"database",
@@ -17,14 +17,19 @@
1717
}
1818
],
1919
"require": {
20-
"php": "^8.1",
21-
"spatie/laravel-package-tools": "^1.14.0",
22-
"illuminate/contracts": "^10.0"
20+
"php": "^8.2",
21+
"illuminate/contracts": "^10.0",
22+
"illuminate/database": "^10.0",
23+
"illuminate/queue": "^10.0",
24+
"illuminate/support": "^10.0",
25+
"rokde/laravel-utilities": "^1.0",
26+
"spatie/laravel-package-tools": "^1.14.0"
2327
},
2428
"require-dev": {
29+
"doctrine/dbal": "^3.8",
30+
"larastan/larastan": "^2.0.1",
2531
"laravel/pint": "^1.0",
2632
"nunomaduro/collision": "^7.8",
27-
"larastan/larastan": "^2.0.1",
2833
"orchestra/testbench": "^8.8",
2934
"pestphp/pest": "^2.20",
3035
"pestphp/pest-plugin-arch": "^2.5",
@@ -35,13 +40,12 @@
3540
},
3641
"autoload": {
3742
"psr-4": {
38-
"Rokde\\LaravelCloneDatabaseCommand\\": "src/",
39-
"Rokde\\LaravelCloneDatabaseCommand\\Database\\Factories\\": "database/factories/"
43+
"Rokde\\CloneDatabase\\": "src/"
4044
}
4145
},
4246
"autoload-dev": {
4347
"psr-4": {
44-
"Rokde\\LaravelCloneDatabaseCommand\\Tests\\": "tests/",
48+
"Rokde\\CloneDatabase\\Tests\\": "tests/",
4549
"Workbench\\App\\": "workbench/app/"
4650
}
4751
},
@@ -73,10 +77,9 @@
7377
"extra": {
7478
"laravel": {
7579
"providers": [
76-
"Rokde\\LaravelCloneDatabaseCommand\\LaravelCloneDatabaseCommandServiceProvider"
80+
"Rokde\\CloneDatabase\\CloneDatabaseServiceProvider"
7781
],
7882
"aliases": {
79-
"LaravelCloneDatabaseCommand": "Rokde\\LaravelCloneDatabaseCommand\\Facades\\LaravelCloneDatabaseCommand"
8083
}
8184
}
8285
},

src/Actions/CountRecords.php

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Rokde\CloneDatabase\Actions;
4+
5+
use Illuminate\Support\Facades\DB;
6+
7+
class CountRecords
8+
{
9+
public function __invoke(string $table, string $connectionName = 'source'): int
10+
{
11+
return DB::connection($connectionName)
12+
->table($table)
13+
->count();
14+
}
15+
}

src/Actions/DeleteRecords.php

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Rokde\CloneDatabase\Actions;
4+
5+
use Illuminate\Support\Facades\DB;
6+
use Rokde\CloneDatabase\Events\RecordsDeleted;
7+
8+
class DeleteRecords
9+
{
10+
public function __invoke(string $table, string $connectionName = 'target'): void
11+
{
12+
DB::connection($connectionName)
13+
->delete('DELETE FROM `'.$table.'`;');
14+
15+
RecordsDeleted::dispatch($table);
16+
}
17+
}

src/Actions/InsertingRecords.php

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace Rokde\CloneDatabase\Actions;
4+
5+
use Doctrine\DBAL\Schema\AbstractSchemaManager;
6+
use Illuminate\Support\Collection;
7+
use Illuminate\Support\Facades\DB;
8+
use Rokde\CloneDatabase\Events\RecordInserted;
9+
use Rokde\CloneDatabase\Models\DatabaseSyncConfiguration;
10+
11+
class InsertingRecords
12+
{
13+
protected MutateRecord $mutateRecordAction;
14+
15+
public function __construct(
16+
protected string $table,
17+
protected array $mutations = [],
18+
protected int $chunks = 100,
19+
protected int $limit = DatabaseSyncConfiguration::LIMIT_UNLIMITED,
20+
protected string $sourceConnectionName = 'source',
21+
protected string $targetConnectionName = 'target'
22+
) {
23+
$this->mutateRecordAction = new MutateRecord($table, $this->mutations);
24+
}
25+
26+
/**
27+
* @throws \Doctrine\DBAL\Exception
28+
*/
29+
public function __invoke(AbstractSchemaManager $sourceSchema, ?callable $stepWiseCallback = null): void
30+
{
31+
$orderByColumn = (new RetrieveOrderColumnFromSchema())($sourceSchema, $this->table);
32+
33+
$callback = function (Collection $records) use ($stepWiseCallback) {
34+
$records->each(function (\stdClass $result, int $index) use ($stepWiseCallback) {
35+
$record = get_object_vars($result);
36+
37+
// mutate data
38+
$record = $this->mutateRecordAction->__invoke($record);
39+
40+
DB::connection($this->targetConnectionName)
41+
->table($this->table)
42+
->insert($record);
43+
44+
RecordInserted::dispatch($this->table, $index, $this->chunks);
45+
46+
if ($stepWiseCallback) {
47+
call_user_func($stepWiseCallback);
48+
}
49+
50+
});
51+
};
52+
53+
$query = DB::connection($this->sourceConnectionName)
54+
->table($this->table)
55+
->orderBy($orderByColumn);
56+
57+
if ($this->limit === DatabaseSyncConfiguration::LIMIT_UNLIMITED) {
58+
$query->chunk($this->chunks, $callback);
59+
} else {
60+
$recordsFetched = 0;
61+
$page = 1;
62+
do {
63+
$records = $query
64+
->offset((++$page - 1) * $this->chunks)
65+
->limit(min($this->chunks, $this->limit - $recordsFetched))
66+
->get();
67+
68+
$callback($records);
69+
70+
$recordsFetched += $records->count();
71+
} while ($recordsFetched < $this->limit && $records->count() > 0);
72+
}
73+
}
74+
}

src/Actions/MutateRecord.php

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace Rokde\CloneDatabase\Actions;
4+
5+
use Illuminate\Support\Arr;
6+
use Rokde\CloneDatabase\Events\MutationApplied;
7+
8+
readonly class MutateRecord
9+
{
10+
public function __construct(protected string $table, protected array $mutations = [])
11+
{
12+
13+
}
14+
15+
public function __invoke(array $record): array
16+
{
17+
$mutationsApplied = 0;
18+
foreach ($record as $key => $value) {
19+
if (Arr::has($this->mutations, $key)) {
20+
$record[$key] = value($this->mutations[$key], $value);
21+
$mutationsApplied++;
22+
}
23+
}
24+
25+
MutationApplied::dispatchIf($mutationsApplied > 0, $this->table, $mutationsApplied);
26+
27+
return $record;
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace Rokde\CloneDatabase\Actions;
4+
5+
use Doctrine\DBAL\Schema\AbstractSchemaManager;
6+
use Doctrine\DBAL\Schema\Index;
7+
use Rokde\CloneDatabase\Events\OrderColumnFound;
8+
9+
class RetrieveOrderColumnFromSchema
10+
{
11+
/**
12+
* @throws \Doctrine\DBAL\Exception
13+
*/
14+
public function __invoke(AbstractSchemaManager $schema, string $table): string
15+
{
16+
$tableIntrospection = $schema->introspectTable($table);
17+
18+
$pk = $tableIntrospection->getPrimaryKey();
19+
$orderByColumn = ($pk instanceof Index)
20+
? $pk->getColumns()[0]
21+
: current($tableIntrospection->getColumns())->getName();
22+
23+
OrderColumnFound::dispatch($table, $orderByColumn);
24+
25+
return $orderByColumn;
26+
}
27+
}

src/Actions/Synchronize.php

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace Rokde\CloneDatabase\Actions;
4+
5+
use Illuminate\Database\ConnectionResolverInterface;
6+
use Rokde\CloneDatabase\Models\DatabaseSyncConfiguration;
7+
8+
readonly class Synchronize
9+
{
10+
public function __construct(
11+
protected ConnectionResolverInterface $connections,
12+
protected DatabaseSyncConfiguration $config
13+
) {
14+
15+
}
16+
17+
public function __invoke(): void
18+
{
19+
/** @var \Illuminate\Database\Connection|\Illuminate\Database\ConnectionInterface $sourceConnection */
20+
$sourceConnection = $this->connections->connectUsing(
21+
$this->config->sourceConnectionName(),
22+
$this->config->sourceConnectionConfig(),
23+
true,
24+
);
25+
26+
/** @var \Illuminate\Database\Connection|\Illuminate\Database\ConnectionInterface $targetConnection */
27+
$targetConnection = $this->connections->connectUsing(
28+
$this->config->targetConnectionName(),
29+
$this->config->targetConnectionConfig(),
30+
true,
31+
);
32+
33+
// start process
34+
try {
35+
$targetConnection->getSchemaBuilder()->disableForeignKeyConstraints();
36+
37+
// structure
38+
(new SynchronizeStructure(
39+
dropExistingTables: $this->config->shouldDropTables(),
40+
keepUnhandledTablesOnTarget: $this->config->shouldKeepUnhandledTablesOnTarget())
41+
)($sourceConnection, $targetConnection);
42+
43+
$sourceSchema = $sourceConnection->getDoctrineSchemaManager();
44+
// copy data
45+
collect($sourceSchema->listTableNames())
46+
->each(function (string $tableName) use ($sourceSchema) {
47+
48+
(new SynchronizeTable(
49+
table: $tableName,
50+
sourceConnectionName: $this->config->sourceConnectionName(),
51+
targetConnectionName: $this->config->targetConnectionName(),
52+
deleteRecords: $this->config->shouldDeleteRecords(),
53+
))(
54+
mutations: $this->config->mutationsFor($tableName),
55+
chunks: $this->config->chunksFor($tableName),
56+
limit: $this->config->limitFor($tableName),
57+
sourceSchema: $sourceSchema,
58+
);
59+
60+
});
61+
62+
$targetConnection->getSchemaBuilder()->enableForeignKeyConstraints();
63+
64+
$targetSchema = $targetConnection->getDoctrineSchemaManager();
65+
// views
66+
(new SynchronizeViews())($sourceSchema, $targetSchema);
67+
} catch (\Exception $exception) {
68+
$targetConnection->getSchemaBuilder()->enableForeignKeyConstraints();
69+
}
70+
}
71+
}

0 commit comments

Comments
 (0)