create-nevela 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/project.mjs +11 -0
- package/template/laravel/composer.json +61 -59
- package/template/laravel/config/nevela.php +70 -0
- package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -2
- package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
- package/template/laravel/src/Console/SeedCommand.php +186 -124
- package/template/laravel/src/Console/UpdateCommand.php +64 -3
- package/template/laravel/src/Generator/ResourceGenerator.php +151 -19
- package/template/laravel/src/Http/ResourceRequest.php +4 -1
- package/template/laravel/src/Http/UploadController.php +147 -0
- package/template/laravel/src/Media/FileTypes.php +97 -0
- package/template/laravel/src/Media/ImageOptimizer.php +252 -0
- package/template/laravel/src/Media/Profile.php +94 -0
- package/template/laravel/src/Media/UploadRejected.php +8 -0
- package/template/laravel/src/Media/Uploads.php +325 -0
- package/template/laravel/src/Models/Upload.php +46 -0
- package/template/laravel/src/Nevela.php +20 -1
- package/template/laravel/src/NevelaServiceProvider.php +24 -0
- package/template/laravel/src/Rules/UploadKey.php +27 -0
- package/template/laravel/src/Support/Fake.php +194 -171
- package/template/laravel/src/Support/Field.php +79 -10
- package/template/laravel/src/Support/TemplateSource.php +1 -1
- package/template/web/app/api/[...path]/route.ts +87 -0
- package/template/web/app/dashboard/actions.ts +26 -9
- package/template/web/components/dashboard/dashboard-header.tsx +24 -2
- package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
- package/template/web/components/dashboard/fields/file-field.tsx +25 -31
- package/template/web/lib/files.ts +21 -0
- package/template/web/package.json +2 -2
- package/template.mjs +1 -1
package/package.json
CHANGED
package/project.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
+
import { isNewer, satisfiesCaret } from './packagist.mjs';
|
|
10
11
|
import { spawnCommand } from './shell.mjs';
|
|
11
12
|
|
|
12
13
|
/** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
|
|
@@ -64,6 +65,16 @@ export function repairManifest(manifest, latest) {
|
|
|
64
65
|
if (typeof current === 'string' && /^\d+\.\d+(\.\d+)?$/.test(current)) {
|
|
65
66
|
manifest.require['nevela/laravel'] = caret ?? `^${current.split('.').slice(0, 2).join('.')}`;
|
|
66
67
|
repairs.push(`"nevela/laravel": "${current}" pinned one exact version; it is now "${manifest.require['nevela/laravel']}"`);
|
|
68
|
+
return repairs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Below 1.0 a caret holds the minor version: "^0.1" never reaches 0.2.0, so an update
|
|
72
|
+
// would finish having changed nothing. Updating is asking for the newest, so the range
|
|
73
|
+
// is moved to the one that has it.
|
|
74
|
+
const range = typeof current === 'string' ? /^\^(\d+)\.(\d+)(?:\.\d+)?$/.exec(current) : null;
|
|
75
|
+
if (range && caret && caret !== current && !satisfiesCaret(latest, [Number(range[1]), Number(range[2])]) && isNewer(latest, `${range[1]}.${range[2]}.0`)) {
|
|
76
|
+
manifest.require['nevela/laravel'] = caret;
|
|
77
|
+
repairs.push(`"nevela/laravel": "${current}" doesn't reach ${latest}; it is now "${caret}"`);
|
|
67
78
|
}
|
|
68
79
|
return repairs;
|
|
69
80
|
}
|
|
@@ -1,59 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "nevela/laravel",
|
|
3
|
-
"description": "Nevela for Laravel: describe a resource once and generate its model, migration, validation, policy and REST API, plus the Next.js dashboard screens for it.",
|
|
4
|
-
"type": "library",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"keywords": [
|
|
7
|
-
"laravel",
|
|
8
|
-
"nevela",
|
|
9
|
-
"generator",
|
|
10
|
-
"crud",
|
|
11
|
-
"rest-api",
|
|
12
|
-
"nextjs",
|
|
13
|
-
"dashboard",
|
|
14
|
-
"scaffold"
|
|
15
|
-
],
|
|
16
|
-
"homepage": "https://nevela-docs.vercel.app",
|
|
17
|
-
"authors": [
|
|
18
|
-
{
|
|
19
|
-
"name": "Mark Cole MUKISA",
|
|
20
|
-
"homepage": "https://github.com/MarkColeMukisa"
|
|
21
|
-
}
|
|
22
|
-
],
|
|
23
|
-
"support": {
|
|
24
|
-
"issues": "https://github.com/MarkColeMukisa/nevela/issues",
|
|
25
|
-
"source": "https://github.com/MarkColeMukisa/nevela",
|
|
26
|
-
"docs": "https://nevela-docs.vercel.app"
|
|
27
|
-
},
|
|
28
|
-
"require": {
|
|
29
|
-
"php": "^8.2",
|
|
30
|
-
"illuminate/support": "^11.0|^12.0|^13.0",
|
|
31
|
-
"illuminate/database": "^11.17|^12.0|^13.0",
|
|
32
|
-
"illuminate/http": "^11.0|^12.0|^13.0",
|
|
33
|
-
"illuminate/console": "^11.0|^12.0|^13.0"
|
|
34
|
-
},
|
|
35
|
-
"require-dev": {
|
|
36
|
-
"phpunit/phpunit": "^11.0"
|
|
37
|
-
},
|
|
38
|
-
"suggest": {
|
|
39
|
-
"laravel/sanctum": "Required for the token sign-in endpoints and the default auth:sanctum middleware (php artisan install:api)."
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "nevela/laravel",
|
|
3
|
+
"description": "Nevela for Laravel: describe a resource once and generate its model, migration, validation, policy and REST API, plus the Next.js dashboard screens for it.",
|
|
4
|
+
"type": "library",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"laravel",
|
|
8
|
+
"nevela",
|
|
9
|
+
"generator",
|
|
10
|
+
"crud",
|
|
11
|
+
"rest-api",
|
|
12
|
+
"nextjs",
|
|
13
|
+
"dashboard",
|
|
14
|
+
"scaffold"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://nevela-docs.vercel.app",
|
|
17
|
+
"authors": [
|
|
18
|
+
{
|
|
19
|
+
"name": "Mark Cole MUKISA",
|
|
20
|
+
"homepage": "https://github.com/MarkColeMukisa"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"support": {
|
|
24
|
+
"issues": "https://github.com/MarkColeMukisa/nevela/issues",
|
|
25
|
+
"source": "https://github.com/MarkColeMukisa/nevela",
|
|
26
|
+
"docs": "https://nevela-docs.vercel.app"
|
|
27
|
+
},
|
|
28
|
+
"require": {
|
|
29
|
+
"php": "^8.2",
|
|
30
|
+
"illuminate/support": "^11.0|^12.0|^13.0",
|
|
31
|
+
"illuminate/database": "^11.17|^12.0|^13.0",
|
|
32
|
+
"illuminate/http": "^11.0|^12.0|^13.0",
|
|
33
|
+
"illuminate/console": "^11.0|^12.0|^13.0"
|
|
34
|
+
},
|
|
35
|
+
"require-dev": {
|
|
36
|
+
"phpunit/phpunit": "^11.0"
|
|
37
|
+
},
|
|
38
|
+
"suggest": {
|
|
39
|
+
"laravel/sanctum": "Required for the token sign-in endpoints and the default auth:sanctum middleware (php artisan install:api).",
|
|
40
|
+
"ext-gd": "Optimises uploaded images: resizing, WebP and renditions. Without it images are stored as uploaded.",
|
|
41
|
+
"ext-exif": "Turns phone photos the right way up before their metadata is removed."
|
|
42
|
+
},
|
|
43
|
+
"autoload": {
|
|
44
|
+
"psr-4": {
|
|
45
|
+
"Nevela\\Laravel\\": "src/"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"autoload-dev": {
|
|
49
|
+
"psr-4": {
|
|
50
|
+
"Nevela\\Laravel\\Tests\\": "tests/"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"extra": {
|
|
54
|
+
"laravel": {
|
|
55
|
+
"providers": [
|
|
56
|
+
"Nevela\\Laravel\\NevelaServiceProvider"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"minimum-stability": "stable"
|
|
61
|
+
}
|
|
@@ -33,4 +33,74 @@ return [
|
|
|
33
33
|
/* List defaults, matching Flare's client. */
|
|
34
34
|
'per_page' => 25,
|
|
35
35
|
'max_per_page' => 100,
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
| File and image fields (image:image, manual:file(pdf)).
|
|
39
|
+
|
|
|
40
|
+
| Files go on a disk from config/filesystems.php and are handed out by Nevela's own
|
|
41
|
+
| route, GET {prefix}/_nevela/files/{key}, so nothing needs linking or publishing. Keys
|
|
42
|
+
| are unguessable, but anyone who has one can fetch the file: don't use these fields
|
|
43
|
+
| for documents that must stay private. If a CDN or your web server serves the disk,
|
|
44
|
+
| put its address in NEVELA_UPLOADS_URL and the API's links point there.
|
|
45
|
+
*/
|
|
46
|
+
'uploads' => [
|
|
47
|
+
'disk' => env('NEVELA_UPLOADS_DISK', 'public'),
|
|
48
|
+
'url' => env('NEVELA_UPLOADS_URL'),
|
|
49
|
+
|
|
50
|
+
/* Where untouched originals are kept, so an image can be optimised again later. Never served. */
|
|
51
|
+
'originals_disk' => env('NEVELA_ORIGINALS_DISK', 'local'),
|
|
52
|
+
|
|
53
|
+
/* The largest file a field takes. */
|
|
54
|
+
'max_bytes' => 10 * 1024 * 1024,
|
|
55
|
+
|
|
56
|
+
/* Memory allowed while an image is being optimised: a phone photo needs more than PHP's usual 128M. */
|
|
57
|
+
'memory' => '512M',
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
| How images are optimised. Every image field uses "default" unless it names another:
|
|
61
|
+
| image:image(product). A profile only says what differs from the default.
|
|
62
|
+
|
|
|
63
|
+
| max [width, height] the stored image fits inside. Add 'crop' to fill it exactly.
|
|
64
|
+
| quality 1 to 100.
|
|
65
|
+
| format auto, webp, jpeg, png or avif. Auto keeps transparency and otherwise
|
|
66
|
+
| picks the smallest.
|
|
67
|
+
| renditions extra sizes made alongside, by name. Ask for one by putting its name
|
|
68
|
+
| before the extension: kettle.webp → kettle.thumb.webp
|
|
69
|
+
| keep_original keep the untouched upload on the originals disk.
|
|
70
|
+
| on_error store_original (keep the upload as it came) or reject.
|
|
71
|
+
| max_pixels refuse anything that would decode to more than this.
|
|
72
|
+
*/
|
|
73
|
+
'profiles' => [
|
|
74
|
+
'default' => [
|
|
75
|
+
'max' => [1600, 1600],
|
|
76
|
+
'quality' => 82,
|
|
77
|
+
'format' => 'auto',
|
|
78
|
+
'renditions' => ['thumb' => [400, 400, 'crop']],
|
|
79
|
+
'keep_original' => true,
|
|
80
|
+
'on_error' => 'store_original',
|
|
81
|
+
'max_pixels' => 50_000_000,
|
|
82
|
+
],
|
|
83
|
+
|
|
84
|
+
// A product photograph: smaller than the default, because a catalogue page shows a dozen at once.
|
|
85
|
+
'product' => [
|
|
86
|
+
'max' => [1000, 1000],
|
|
87
|
+
'quality' => 80,
|
|
88
|
+
'renditions' => ['thumb' => [300, 300, 'crop'], 'card' => [600, 600]],
|
|
89
|
+
],
|
|
90
|
+
|
|
91
|
+
// Always square and always small, so it crops: a portrait in a round frame shouldn't letterbox.
|
|
92
|
+
'avatar' => [
|
|
93
|
+
'max' => [400, 400, 'crop'],
|
|
94
|
+
'quality' => 85,
|
|
95
|
+
'renditions' => ['thumb' => [80, 80, 'crop']],
|
|
96
|
+
'keep_original' => false,
|
|
97
|
+
],
|
|
98
|
+
|
|
99
|
+
// A hero or cover image, where width matters more than total pixels.
|
|
100
|
+
'cover' => [
|
|
101
|
+
'max' => [2400, 1200],
|
|
102
|
+
'renditions' => ['thumb' => [600, 300, 'crop']],
|
|
103
|
+
],
|
|
104
|
+
],
|
|
105
|
+
],
|
|
36
106
|
];
|
package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\Migrations\Migration;
|
|
4
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
5
|
+
use Illuminate\Support\Facades\Schema;
|
|
6
|
+
|
|
7
|
+
// What Nevela knows about each uploaded file: where it is, how big it is, and for an
|
|
8
|
+
// image, its dimensions and the smaller renditions made from it.
|
|
9
|
+
return new class extends Migration
|
|
10
|
+
{
|
|
11
|
+
public function up(): void
|
|
12
|
+
{
|
|
13
|
+
Schema::create('nevela_uploads', function (Blueprint $table) {
|
|
14
|
+
$table->uuid('id')->primary();
|
|
15
|
+
$table->string('key', 512)->unique();
|
|
16
|
+
$table->string('disk');
|
|
17
|
+
$table->string('resource')->index();
|
|
18
|
+
$table->string('field');
|
|
19
|
+
$table->string('name');
|
|
20
|
+
$table->string('mime');
|
|
21
|
+
$table->unsignedBigInteger('size');
|
|
22
|
+
$table->unsignedInteger('width')->nullable();
|
|
23
|
+
$table->unsignedInteger('height')->nullable();
|
|
24
|
+
$table->json('renditions')->nullable();
|
|
25
|
+
$table->string('profile')->nullable();
|
|
26
|
+
$table->boolean('optimised')->default(false);
|
|
27
|
+
$table->string('original_key', 512)->nullable();
|
|
28
|
+
$table->unsignedBigInteger('original_size')->nullable();
|
|
29
|
+
$table->string('uploaded_by')->nullable();
|
|
30
|
+
$table->timestamps();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public function down(): void
|
|
35
|
+
{
|
|
36
|
+
Schema::dropIfExists('nevela_uploads');
|
|
37
|
+
}
|
|
38
|
+
};
|
|
@@ -61,9 +61,32 @@ final class GenerateCommand extends Command
|
|
|
61
61
|
public static function generate(Command $command, array $targets, array $all, bool $force): int
|
|
62
62
|
{
|
|
63
63
|
$generator = new ResourceGenerator;
|
|
64
|
-
$
|
|
64
|
+
$byName = [];
|
|
65
|
+
foreach ($all as $descriptor) {
|
|
66
|
+
$byName[$descriptor->name] = $descriptor;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A relation is written on both ends, so the resource a new one points at is
|
|
70
|
+
// regenerated with it: Category gains its products when Product arrives.
|
|
71
|
+
$queue = [];
|
|
65
72
|
foreach ($targets as $descriptor) {
|
|
66
|
-
|
|
73
|
+
$queue[$descriptor->name] = $descriptor;
|
|
74
|
+
foreach ($descriptor->fields as $field) {
|
|
75
|
+
if ($field->kind !== 'belongsTo') {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (! isset($byName[$field->target])) {
|
|
79
|
+
$command->components->error("{$descriptor->name}.{$field->name} belongs to {$field->target}, which doesn't exist yet. Create it first: php artisan nevela:resource {$field->target} --fields=\"name:string\"");
|
|
80
|
+
|
|
81
|
+
return self::FAILURE;
|
|
82
|
+
}
|
|
83
|
+
$queue[$field->target] ??= $byName[$field->target];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
$files = [];
|
|
88
|
+
foreach ($queue as $descriptor) {
|
|
89
|
+
array_push($files, ...$generator->forResource($descriptor, null, $all));
|
|
67
90
|
}
|
|
68
91
|
$files[] = $generator->routes($all);
|
|
69
92
|
$files[] = $generator->registry($all);
|
|
@@ -43,6 +43,14 @@ final class MakeResourceCommand extends Command
|
|
|
43
43
|
|
|
44
44
|
return self::FAILURE;
|
|
45
45
|
}
|
|
46
|
+
// Checked before anything is written: a relation needs the resource it points at.
|
|
47
|
+
foreach ($descriptor->fields as $field) {
|
|
48
|
+
if ($field->kind === 'belongsTo' && $field->target !== $descriptor->name && ! is_file($dir.DIRECTORY_SEPARATOR.Naming::kebab((string) $field->target).'.json')) {
|
|
49
|
+
$this->components->error("{$field->name} belongs to {$field->target}, which doesn't exist yet. Create it first: php artisan nevela:resource {$field->target} --fields=\"name:string\"");
|
|
50
|
+
|
|
51
|
+
return self::FAILURE;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
46
54
|
if (! is_dir($dir)) {
|
|
47
55
|
mkdir($dir, 0775, true);
|
|
48
56
|
}
|
|
@@ -1,124 +1,186 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
namespace Nevela\Laravel\Console;
|
|
4
|
-
|
|
5
|
-
use Illuminate\Console\Command;
|
|
6
|
-
use Illuminate\Database\UniqueConstraintViolationException;
|
|
7
|
-
use Illuminate\Support\Facades\DB;
|
|
8
|
-
use Illuminate\Support\Str;
|
|
9
|
-
use InvalidArgumentException;
|
|
10
|
-
use Nevela\Laravel\
|
|
11
|
-
use Nevela\Laravel\
|
|
12
|
-
use Nevela\Laravel\Support\
|
|
13
|
-
use
|
|
14
|
-
use
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
{
|
|
21
|
-
{--
|
|
22
|
-
{--
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
$
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
$
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
$
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
$this->
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
$
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use Illuminate\Database\UniqueConstraintViolationException;
|
|
7
|
+
use Illuminate\Support\Facades\DB;
|
|
8
|
+
use Illuminate\Support\Str;
|
|
9
|
+
use InvalidArgumentException;
|
|
10
|
+
use Nevela\Laravel\Media\Uploads;
|
|
11
|
+
use Nevela\Laravel\Nevela;
|
|
12
|
+
use Nevela\Laravel\Support\Descriptor;
|
|
13
|
+
use Nevela\Laravel\Support\Fake;
|
|
14
|
+
use Symfony\Component\Console\Helper\ProgressBar;
|
|
15
|
+
use Throwable;
|
|
16
|
+
|
|
17
|
+
final class SeedCommand extends Command
|
|
18
|
+
{
|
|
19
|
+
protected $signature = 'nevela:seed
|
|
20
|
+
{name : The resource to fill, e.g. Product}
|
|
21
|
+
{--count=1000 : How many records to create}
|
|
22
|
+
{--chunk=500 : Rows per insert statement}
|
|
23
|
+
{--fresh : Delete the resource\'s existing records first}';
|
|
24
|
+
|
|
25
|
+
protected $description = 'Fill a resource with plausible records, and say how long it took';
|
|
26
|
+
|
|
27
|
+
private const MAX_COUNT = 5_000_000;
|
|
28
|
+
|
|
29
|
+
/** 400 blocks of MAX_COUNT stay under 2^31, so a unique integer column can hold the numbers. */
|
|
30
|
+
private const BLOCKS = 400;
|
|
31
|
+
|
|
32
|
+
private const ATTEMPTS = 5;
|
|
33
|
+
|
|
34
|
+
public function handle(): int
|
|
35
|
+
{
|
|
36
|
+
try {
|
|
37
|
+
$resource = Nevela::resource(Str::studly($this->argument('name')));
|
|
38
|
+
} catch (InvalidArgumentException $e) {
|
|
39
|
+
$this->components->error($e->getMessage());
|
|
40
|
+
|
|
41
|
+
return self::FAILURE;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$count = filter_var($this->option('count'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => self::MAX_COUNT]]);
|
|
45
|
+
$chunk = filter_var($this->option('chunk'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 5000]]);
|
|
46
|
+
if ($count === false || $chunk === false) {
|
|
47
|
+
$this->components->error('--count must be between 1 and 5,000,000, and --chunk between 1 and 5,000.');
|
|
48
|
+
|
|
49
|
+
return self::FAILURE;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// SQLite allows 32,766 bound values per statement; stay well inside it on wide tables.
|
|
53
|
+
$chunk = min($chunk, max(1, intdiv(30_000, count($resource->fields) + 3)));
|
|
54
|
+
$fresh = (bool) $this->option('fresh');
|
|
55
|
+
|
|
56
|
+
$existing = $fresh ? 0 : (int) DB::table($resource->table)->count();
|
|
57
|
+
foreach ($resource->fields as $field) {
|
|
58
|
+
$capacity = Fake::capacity($field);
|
|
59
|
+
if ($capacity !== null && $existing + $count > $capacity) {
|
|
60
|
+
$room = number_format(max(0, $capacity - $existing));
|
|
61
|
+
$this->components->error("\"{$field->name}\" is unique and can only hold ".number_format($capacity)." different values, so there is room for {$room} more {$resource->pluralLabel}, not ".number_format($count).'.');
|
|
62
|
+
|
|
63
|
+
return self::FAILURE;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --fresh deletes first, and a record others are required to belong to can't be deleted.
|
|
68
|
+
// Said here, before any placeholder pictures are made for a run that can't happen.
|
|
69
|
+
if ($fresh) {
|
|
70
|
+
foreach (Nevela::all() as $other) {
|
|
71
|
+
foreach ($other->fields as $field) {
|
|
72
|
+
if ($field->kind === 'belongsTo' && $field->target === $resource->name && $field->required && $other->name !== $resource->name
|
|
73
|
+
&& ($children = (int) DB::table($other->table)->count()) > 0) {
|
|
74
|
+
$this->components->error(number_format($children)." {$other->pluralLabel} belong to the {$resource->pluralLabel} that --fresh would delete. Remove them first, or seed without --fresh.");
|
|
75
|
+
|
|
76
|
+
return self::FAILURE;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
$started = microtime(true);
|
|
83
|
+
try {
|
|
84
|
+
$pools = $this->pools($resource);
|
|
85
|
+
} catch (InvalidArgumentException $e) {
|
|
86
|
+
$this->components->error($e->getMessage());
|
|
87
|
+
|
|
88
|
+
return self::FAILURE;
|
|
89
|
+
}
|
|
90
|
+
$removed = 0;
|
|
91
|
+
$bar = $this->output->createProgressBar($count);
|
|
92
|
+
try {
|
|
93
|
+
for ($attempt = 1; ; $attempt++) {
|
|
94
|
+
// Unique fields are numbered from a block of MAX_COUNT picked per run (a run is at
|
|
95
|
+
// most that long). Two runs only meet if they pick the same block; then the insert
|
|
96
|
+
// fails, the transaction rolls back and another block is picked.
|
|
97
|
+
$offset = $fresh && $attempt === 1 ? 0 : mt_rand(0, self::BLOCKS - 1) * self::MAX_COUNT;
|
|
98
|
+
$bar->start();
|
|
99
|
+
try {
|
|
100
|
+
$removed = DB::transaction(fn () => $this->fill($resource, $count, $chunk, $offset, $fresh, $bar, $pools));
|
|
101
|
+
break;
|
|
102
|
+
} catch (UniqueConstraintViolationException $e) {
|
|
103
|
+
if ($attempt === self::ATTEMPTS) {
|
|
104
|
+
throw $e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
$bar->finish();
|
|
109
|
+
$this->newLine(2);
|
|
110
|
+
} catch (Throwable $e) {
|
|
111
|
+
$this->newLine(2);
|
|
112
|
+
$this->components->error("Seeding {$resource->pluralLabel} failed, and the table is as it was: ".Str::limit($e->getMessage(), 300));
|
|
113
|
+
|
|
114
|
+
return self::FAILURE;
|
|
115
|
+
}
|
|
116
|
+
if ($fresh) {
|
|
117
|
+
$this->components->info('Removed '.number_format($removed)." existing {$resource->pluralLabel}.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
$seconds = microtime(true) - $started;
|
|
121
|
+
$time = $seconds < 1 ? round($seconds * 1000).' ms' : number_format($seconds, 2).' s';
|
|
122
|
+
$rate = number_format($count / max($seconds, 0.001));
|
|
123
|
+
$this->components->info('Seeded '.number_format($count)." {$resource->pluralLabel} in {$time} ({$rate} rows/s).");
|
|
124
|
+
$this->components->twoColumnDetail("{$resource->pluralLabel} in the database", number_format(DB::table($resource->table)->count()));
|
|
125
|
+
|
|
126
|
+
return self::SUCCESS;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What a relation or an image field can be filled with: the records that exist to point
|
|
131
|
+
* at, and a handful of pictures made for the purpose.
|
|
132
|
+
*
|
|
133
|
+
* @return array<string, list<string>>
|
|
134
|
+
*
|
|
135
|
+
* @throws InvalidArgumentException when a required field has nothing to draw from
|
|
136
|
+
*/
|
|
137
|
+
private function pools(Descriptor $resource): array
|
|
138
|
+
{
|
|
139
|
+
$pools = [];
|
|
140
|
+
foreach ($resource->fields as $field) {
|
|
141
|
+
if ($field->kind === 'belongsTo') {
|
|
142
|
+
$target = $field->target === $resource->name ? $resource : Nevela::resource((string) $field->target);
|
|
143
|
+
$ids = DB::table($target->table)->inRandomOrder()->limit(1000)->pluck('id')->map(fn ($id) => (string) $id)->all();
|
|
144
|
+
if ($field->unique) {
|
|
145
|
+
throw new InvalidArgumentException("\"{$field->name}\" is unique, so each {$target->label} can be used once. The seeder doesn't fill one-to-one relations; add those records yourself.");
|
|
146
|
+
}
|
|
147
|
+
if ($ids === [] && $field->required && $target->name !== $resource->name) {
|
|
148
|
+
throw new InvalidArgumentException("Every {$resource->label} needs a {$target->label}, and there are none yet. Seed them first: php artisan nevela:seed {$target->name}");
|
|
149
|
+
}
|
|
150
|
+
$pools[$field->name] = $ids;
|
|
151
|
+
} elseif ($field->kind === 'file') {
|
|
152
|
+
$pools[$field->name] = $field->isImage() ? Uploads::placeholders($resource, $field) : [];
|
|
153
|
+
if ($pools[$field->name] === [] && $field->required) {
|
|
154
|
+
throw new InvalidArgumentException($field->isImage()
|
|
155
|
+
? "\"{$field->name}\" is a required image, and PHP's GD extension isn't installed to make placeholder pictures with."
|
|
156
|
+
: "\"{$field->name}\" is a required file, which the seeder can't make up. Make it optional ({$field->name}:file(…)?) or add records yourself.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return $pools;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Runs inside the transaction: the optional delete and every insert succeed or fail together.
|
|
166
|
+
*
|
|
167
|
+
* @param array<string, list<string>> $pools
|
|
168
|
+
*/
|
|
169
|
+
private function fill(Descriptor $resource, int $count, int $chunk, int $offset, bool $fresh, ProgressBar $bar, array $pools = []): int
|
|
170
|
+
{
|
|
171
|
+
$removed = $fresh ? DB::table($resource->table)->delete() : 0;
|
|
172
|
+
$now = time();
|
|
173
|
+
for ($done = 0; $done < $count; $done += $chunk) {
|
|
174
|
+
$rows = [];
|
|
175
|
+
for ($i = $done; $i < min($count, $done + $chunk); $i++) {
|
|
176
|
+
// Spread over the last 60 days, so the dashboard's "this week" and trend have something to show.
|
|
177
|
+
$created = date('Y-m-d H:i:s', $now - mt_rand(0, 60 * 86400));
|
|
178
|
+
$rows[] = ['id' => (string) Str::uuid7()] + Fake::row($resource, $offset + $i + 1, $pools) + ['created_at' => $created, 'updated_at' => $created];
|
|
179
|
+
}
|
|
180
|
+
DB::table($resource->table)->insert($rows);
|
|
181
|
+
$bar->advance(count($rows));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return $removed;
|
|
185
|
+
}
|
|
186
|
+
}
|