create-nevela 0.5.1 → 0.5.3

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.
Files changed (31) hide show
  1. package/index.mjs +32 -2
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +16 -0
  5. package/template/laravel/src/Concerns/Trashable.php +63 -0
  6. package/template/laravel/src/Console/GenerateCommand.php +43 -0
  7. package/template/laravel/src/Console/TrashCommand.php +33 -0
  8. package/template/laravel/src/Generator/ResourceGenerator.php +84 -1
  9. package/template/laravel/src/Http/ResourceRequest.php +108 -0
  10. package/template/laravel/src/Http/TrashController.php +190 -0
  11. package/template/laravel/src/Nevela.php +162 -15
  12. package/template/laravel/src/NevelaServiceProvider.php +19 -0
  13. package/template/laravel/src/Support/Insights.php +78 -0
  14. package/template/laravel/src/Trash/Trash.php +151 -0
  15. package/template/laravel/src/Trash/TrashedScope.php +21 -0
  16. package/template/web/app/dashboard/actions.ts +79 -0
  17. package/template/web/app/dashboard/layout.tsx +4 -2
  18. package/template/web/app/dashboard/trash/actions.ts +78 -0
  19. package/template/web/app/dashboard/trash/page.tsx +167 -0
  20. package/template/web/components/dashboard/bulk-create-grid.tsx +326 -0
  21. package/template/web/components/dashboard/dashboard-sidebar.tsx +5 -1
  22. package/template/web/components/dashboard/record-actions.tsx +14 -5
  23. package/template/web/components/dashboard/record-detail.tsx +3 -0
  24. package/template/web/components/dashboard/resource-insights.tsx +219 -0
  25. package/template/web/components/dashboard/resource-table.tsx +10 -0
  26. package/template/web/components/dashboard/row-actions.tsx +12 -7
  27. package/template/web/components/dashboard/table-selection.tsx +27 -7
  28. package/template/web/components/dashboard/trash-actions.tsx +178 -0
  29. package/template/web/lib/trash-info.ts +13 -0
  30. package/template/web/lib/trash.ts +66 -0
  31. package/template/web/package.json +1 -1
package/index.mjs CHANGED
@@ -75,7 +75,7 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
75
75
  nevela dev Run the API and the dashboard
76
76
  nevela status Check versions, migrations, users and the dashboard
77
77
  nevela resource <Name> --fields="…" [--seed]
78
- nevela seed | user | generate | migrate | artisan <command>
78
+ nevela seed | user | generate | migrate | trash | artisan <command>
79
79
 
80
80
  ${bold('Options')}
81
81
  --pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
@@ -361,6 +361,10 @@ async function inProject(name, args) {
361
361
  }
362
362
  if (name !== 'upgrade') process.exit(forward(project, name, args));
363
363
 
364
+ // The name first, before anything that waits on the network. A newer installer this
365
+ // one hands over to knows it has been shown, and prints one line in its place.
366
+ showTitle();
367
+
364
368
  // Upgrading is the one command that must not run on stale code itself: it is the newest
365
369
  // installer that knows what an older app needs repaired. So even a nevela command that
366
370
  // hasn't been updated in a while upgrades an app correctly.
@@ -371,9 +375,22 @@ async function inProject(name, args) {
371
375
  if (status !== null) process.exit(status);
372
376
  }
373
377
  }
374
- console.log(`\n ${indigo(bold('Nevela'))} ${dim('upgrade')}\n`);
378
+ const before = appVersion(project);
379
+ const looking = args.includes('--check') ? ' (checking only: nothing will be changed)' : '';
380
+ console.log(` ${indigo(`Nevela upgrade — this app: ${before ? `v${before}` : 'Nevela not installed in it yet'}`)}${dim(looking)}\n`);
375
381
  const releases = await latestRelease();
376
382
  const status = await upgrade(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
383
+ // Where it ended up, read from the app itself and not from what was asked for.
384
+ if (status === 0 && !args.includes('--check') && !args.includes('--undo')) {
385
+ const now = appVersion(project);
386
+ if (now) {
387
+ console.log(`\n ${green(`✔ ${now === before ? `This app is on Nevela v${now}, the version it was on.` : `This app is now on Nevela v${now}.`}`)}\n`);
388
+ }
389
+ // A release is on Packagist a few minutes before every Composer can see it. Finishing
390
+ // on the older one without a word would look like there was nothing newer.
391
+ const behind = behindNotice(now, releases);
392
+ if (behind) console.log(`${behind}\n`);
393
+ }
377
394
  process.exit(status);
378
395
  }
379
396
 
@@ -435,6 +452,19 @@ function otherCopies() {
435
452
  for (const removal of removals) console.log(` ${bold(removal)}`);
436
453
  }
437
454
 
455
+ /**
456
+ * What to say when an upgrade finished on a version older than the newest release, or null
457
+ * when it didn't. `now` is what the app has, `latest` what Packagist lists.
458
+ */
459
+ function behindNotice(now, latest) {
460
+ if (!now || !latest || !isNewer(latest, now)) return null;
461
+ return [
462
+ ` ${red('!')} Nevela ${bold(`v${latest}`)} is out, and Composer didn't bring it in.`,
463
+ ` A release takes a few minutes to reach Composer. Run ${bold('nevela upgrade')} again shortly.`,
464
+ ` ${dim(`Still on v${now} after that? This says what holds it back: cd apps/api && composer why-not nevela/laravel ${latest}`)}`,
465
+ ].join('\n');
466
+ }
467
+
438
468
  /** The version of Nevela an app has installed, read from the package, or null. */
439
469
  function appVersion(project) {
440
470
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
package/project.mjs CHANGED
@@ -11,7 +11,7 @@ import { isNewer, satisfiesCaret } from './packagist.mjs';
11
11
  import { spawnCommand } from './shell.mjs';
12
12
 
13
13
  /** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
14
- const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user'];
14
+ const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user', 'trash'];
15
15
  /** These go to the artisan command of the same name. */
16
16
  const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
17
17
 
@@ -114,6 +114,22 @@ return [
114
114
  'per_page' => 25,
115
115
  'max_per_page' => 100,
116
116
 
117
+ /*
118
+ | The trash. A deleted record is kept, hidden from every list and count, and can be
119
+ | restored from the dashboard's Trash page for this many days. Then it is removed for
120
+ | good: each night by `nevela:trash`, which needs Laravel's scheduler running, and
121
+ | whenever someone opens the trash. Null keeps deleted records until someone removes them.
122
+ */
123
+ 'trash' => [
124
+ 'days' => 30,
125
+ ],
126
+
127
+ /*
128
+ | The most rows POST /{slug}/_bulk creates in one request (the dashboard's "Add several"
129
+ | grid). They are saved in one transaction, so this is also how long one can run.
130
+ */
131
+ 'bulk_max' => 500,
132
+
117
133
  /*
118
134
  | File and image fields (image:image, manual:file(pdf)).
119
135
  |
@@ -0,0 +1,63 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Concerns;
4
+
5
+ use Illuminate\Database\Eloquent\SoftDeletes;
6
+ use Illuminate\Support\Facades\Schema;
7
+ use Nevela\Laravel\Trash\TrashedScope;
8
+
9
+ /**
10
+ * A model whose deleted records go to the trash, where they can be restored for a while.
11
+ *
12
+ * This is Laravel's own soft delete, with one difference: it only takes effect once the
13
+ * table has its `deleted_at` column. An app that has upgraded and not yet migrated keeps
14
+ * working exactly as before, with delete meaning delete, where plain SoftDeletes would
15
+ * put `deleted_at is null` into every query on a table that has no such column.
16
+ *
17
+ * Every generated model uses it. Everything Laravel's soft delete offers is here:
18
+ * `$model->restore()`, `$model->forceDelete()`, `$model->trashed()`, and on a query
19
+ * `withTrashed()` and `onlyTrashed()`.
20
+ *
21
+ * @mixin \Illuminate\Database\Eloquent\Model
22
+ */
23
+ trait Trashable
24
+ {
25
+ use SoftDeletes;
26
+
27
+ /** Whether this model's table has somewhere to keep a deleted record. Only "yes" is remembered. */
28
+ private static bool $hasTrash = false;
29
+
30
+ public static function trashes(): bool
31
+ {
32
+ if (static::$hasTrash) {
33
+ return true;
34
+ }
35
+ $model = new static;
36
+
37
+ // The column can arrive later in the same process (a migration run from a command),
38
+ // so "no" is asked again and "yes" is kept.
39
+ return static::$hasTrash = Schema::connection($model->getConnectionName())->hasColumn($model->getTable(), $model->getDeletedAtColumn());
40
+ }
41
+
42
+ /** Ask the database again next time. For a test that adds or drops the column within one process. */
43
+ public static function forgetTrash(): void
44
+ {
45
+ static::$hasTrash = false;
46
+ }
47
+
48
+ /** In place of SoftDeletes' own: the same scope, applied only where there is a trash. */
49
+ public static function bootSoftDeletes(): void
50
+ {
51
+ static::addGlobalScope(new TrashedScope);
52
+ }
53
+
54
+ public static function bootTrashable(): void
55
+ {
56
+ // With no column to mark it in, a delete removes the row, as it always did.
57
+ static::deleting(function (self $model) {
58
+ if (! static::trashes()) {
59
+ $model->forceDeleting = true;
60
+ }
61
+ });
62
+ }
63
+ }
@@ -3,6 +3,7 @@
3
3
  namespace Nevela\Laravel\Console;
4
4
 
5
5
  use Illuminate\Console\Command;
6
+ use Illuminate\Support\Facades\Schema;
6
7
  use InvalidArgumentException;
7
8
  use Nevela\Laravel\Generator\ResourceGenerator;
8
9
  use Nevela\Laravel\Generator\Writer;
@@ -17,6 +18,41 @@ final class GenerateCommand extends Command
17
18
 
18
19
  protected $description = 'Regenerate Laravel code and Flare descriptors from nevela/resources/*.json';
19
20
 
21
+ /**
22
+ * Whether a resource's table needs the migration that gives it a trash.
23
+ *
24
+ * Not when its own migration already makes the column, and not when the column is
25
+ * there without one of ours having been written: then it came from a migration of
26
+ * the app's own, and ours would take that column away again when rolled back.
27
+ *
28
+ * @param list<string> $createMigrations The contents of the table's own migration(s); none for a resource that is new
29
+ * @param bool $written Whether an add_trash_to_… migration exists for the table already
30
+ * @param bool $columnExists Whether the table has deleted_at in the database now
31
+ */
32
+ public static function needsTrashMigration(array $createMigrations, bool $written, bool $columnExists): bool
33
+ {
34
+ if ($createMigrations === []) {
35
+ return false;
36
+ }
37
+ foreach ($createMigrations as $contents) {
38
+ if (preg_match('/softDeletes|deleted_at/', $contents) === 1) {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ return $written || ! $columnExists;
44
+ }
45
+
46
+ /** Whether the table has the column now. No, where there is no database to ask. */
47
+ private static function hasTrashColumn(string $table): bool
48
+ {
49
+ try {
50
+ return Schema::hasColumn($table, 'deleted_at');
51
+ } catch (\Throwable) {
52
+ return false;
53
+ }
54
+ }
55
+
20
56
  public function handle(): int
21
57
  {
22
58
  Nevela::forget();
@@ -87,6 +123,13 @@ final class GenerateCommand extends Command
87
123
  $files = [];
88
124
  foreach ($queue as $descriptor) {
89
125
  array_push($files, ...$generator->forResource($descriptor, null, $all));
126
+ // A table made before there was a trash gets a migration that gives it one. A new
127
+ // resource's own migration, written by the line above, already has the column.
128
+ $made = array_map(fn (string $file) => (string) file_get_contents($file), glob(database_path("migrations/*_create_{$descriptor->table}_table.php")) ?: []);
129
+ $written = (glob(database_path("migrations/*_add_trash_to_{$descriptor->table}_table.php")) ?: []) !== [];
130
+ if (self::needsTrashMigration($made, $written, self::hasTrashColumn($descriptor->table))) {
131
+ $files[] = $generator->trashMigrationFile($descriptor);
132
+ }
90
133
  }
91
134
  $files[] = $generator->routes($all);
92
135
  $files[] = $generator->registry($all);
@@ -0,0 +1,33 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use Nevela\Laravel\Trash\Trash;
7
+
8
+ final class TrashCommand extends Command
9
+ {
10
+ protected $signature = 'nevela:trash';
11
+
12
+ protected $description = 'Remove, for good, the deleted records whose time in the trash has run out';
13
+
14
+ public function handle(): int
15
+ {
16
+ $days = Trash::days();
17
+ if ($days === null) {
18
+ $this->components->info('Deleted records are kept until someone removes them (nevela.trash.days is off), so there is nothing to do.');
19
+
20
+ return self::SUCCESS;
21
+ }
22
+
23
+ $removed = Trash::purgeExpired();
24
+ foreach ($removed as $resource => $count) {
25
+ $this->components->twoColumnDetail($resource, number_format($count).' removed');
26
+ }
27
+ $this->components->info($removed === []
28
+ ? "Nothing in the trash is older than {$days} days."
29
+ : 'Removed '.number_format(array_sum($removed))." record(s) deleted more than {$days} days ago.");
30
+
31
+ return self::SUCCESS;
32
+ }
33
+ }
@@ -226,7 +226,7 @@ final class ResourceGenerator
226
226
 
227
227
  // What you type => the artisan command it runs.
228
228
  // "update" is what upgrade was called before 0.3.0; it still works.
229
- \$nevela = ['resource', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version'];
229
+ \$nevela = ['resource', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version', 'trash'];
230
230
  \$artisan = ['migrate', 'tinker', 'test', 'serve'];
231
231
  if (in_array(\$name, ['--version', '-v', '-V'], true)) {
232
232
  \$name = 'version';
@@ -255,6 +255,7 @@ final class ResourceGenerator
255
255
  php nevela generate regenerate after editing a descriptor
256
256
  php nevela seed Product fill a resource with records
257
257
  php nevela user create someone who can sign in
258
+ php nevela trash remove deleted records whose time in the trash is up
258
259
  php nevela upgrade bring this app to the latest Nevela
259
260
  php nevela version which Nevela this is
260
261
 
@@ -382,6 +383,8 @@ final class ResourceGenerator
382
383
  foreach ($all as $d) {
383
384
  $controller = "\\App\\Http\\Controllers\\Api\\{$d->name}Controller::class";
384
385
  $lines[] = "Route::get('{$d->slug}/_stats', [{$controller}, 'stats'])->name('{$d->slug}.stats');";
386
+ $lines[] = "Route::get('{$d->slug}/_insights', [{$controller}, 'insights'])->name('{$d->slug}.insights');";
387
+ $lines[] = "Route::post('{$d->slug}/_bulk', [{$controller}, 'bulk'])->name('{$d->slug}.bulk');";
385
388
  $lines[] = "Route::apiResource('{$d->slug}', {$controller});";
386
389
  }
387
390
  $body = implode("\n", $lines);
@@ -461,6 +464,9 @@ final class ResourceGenerator
461
464
  protected \$table = '{$d->table}';
462
465
 
463
466
  {$m}
467
+ // Deleted records go to the trash, where they can be restored for a while.
468
+ use \Nevela\Laravel\Concerns\Trashable;
469
+
464
470
  protected \$fillable = [{$fillable}];
465
471
 
466
472
  protected function casts(): array
@@ -533,6 +539,8 @@ final class ResourceGenerator
533
539
  \$table->uuid('id')->primary();
534
540
  {$cols}
535
541
  \$table->timestamps();
542
+ // When a record was deleted. Until it is removed for good it sits in the trash.
543
+ \$table->softDeletes();
536
544
  });
537
545
  }
538
546
 
@@ -545,6 +553,55 @@ final class ResourceGenerator
545
553
  PHP;
546
554
  }
547
555
 
556
+ /**
557
+ * For a table made before there was a trash: the column that gives it one.
558
+ *
559
+ * Written once, by `nevela:generate`, for a resource whose own migration doesn't
560
+ * have the column. Until it is run, deleting a record removes it as before.
561
+ */
562
+ public function trashMigration(Descriptor $d): string
563
+ {
564
+ return <<<PHP
565
+ <?php
566
+
567
+ use Illuminate\Database\Migrations\Migration;
568
+ use Illuminate\Database\Schema\Blueprint;
569
+ use Illuminate\Support\Facades\Schema;
570
+
571
+ // Gives {$d->pluralLabel} a trash: a deleted record is kept, hidden, and can be restored
572
+ // for a while (config/nevela.php, trash.days). Generated once by Nevela.
573
+ return new class extends Migration
574
+ {
575
+ public function up(): void
576
+ {
577
+ if (Schema::hasTable('{$d->table}') && ! Schema::hasColumn('{$d->table}', 'deleted_at')) {
578
+ Schema::table('{$d->table}', function (Blueprint \$table) {
579
+ \$table->softDeletes();
580
+ });
581
+ }
582
+ }
583
+
584
+ public function down(): void
585
+ {
586
+ if (Schema::hasColumn('{$d->table}', 'deleted_at')) {
587
+ Schema::table('{$d->table}', function (Blueprint \$table) {
588
+ \$table->dropSoftDeletes();
589
+ });
590
+ }
591
+ }
592
+ };
593
+
594
+ PHP;
595
+ }
596
+
597
+ /** The file `trashMigration()` goes in. Not made again once one exists for the table, whatever its date. */
598
+ public function trashMigrationFile(Descriptor $d, ?string $timestamp = null): GeneratedFile
599
+ {
600
+ $timestamp ??= date('Y_m_d_His');
601
+
602
+ return new GeneratedFile(GeneratedFile::TARGET_API, "database/migrations/{$timestamp}_add_trash_to_{$d->table}_table.php", $this->trashMigration($d), GeneratedFile::MODE_ONCE, "database/migrations/*_add_trash_to_{$d->table}_table.php");
603
+ }
604
+
548
605
  /** @param list<Descriptor> $all */
549
606
  public function request(Descriptor $d, array $all = []): string
550
607
  {
@@ -749,6 +806,13 @@ final class ResourceGenerator
749
806
  return Nevela::stats({$d->name}::query(), '{$d->name}', \$request);
750
807
  }
751
808
 
809
+ public function insights(Request \$request): JsonResponse
810
+ {
811
+ Gate::authorize('viewAny', {$d->name}::class);
812
+
813
+ return Nevela::insights({$d->name}::query(), '{$d->name}', \$request);
814
+ }
815
+
752
816
  public function store({$d->name}Request \$request): JsonResponse
753
817
  {
754
818
  \${$var} = {$d->name}::create(\$request->values());
@@ -759,6 +823,13 @@ final class ResourceGenerator
759
823
  ->header('Location', \$request->url().'/'.\${$var}->getKey());
760
824
  }
761
825
 
826
+ public function bulk(Request \$request): JsonResponse
827
+ {
828
+ Gate::authorize('create', {$d->name}::class);
829
+
830
+ return Nevela::createMany({$d->name}::class, {$d->name}Request::class, {$d->name}Resource::class, \$request);
831
+ }
832
+
762
833
  public function show({$d->name} \${$var}): {$d->name}Resource
763
834
  {
764
835
  Gate::authorize('view', \${$var});
@@ -836,6 +907,18 @@ final class ResourceGenerator
836
907
  {
837
908
  return \$user->can('{$d->table}.delete');
838
909
  }
910
+
911
+ /** Bring a deleted one back from the trash. */
912
+ public function restore(User \$user, {$d->name} \${$var}): bool
913
+ {
914
+ return \$user->can('{$d->table}.delete');
915
+ }
916
+
917
+ /** Remove a deleted one from the trash, for good. */
918
+ public function forceDelete(User \$user, {$d->name} \${$var}): bool
919
+ {
920
+ return \$user->can('{$d->table}.delete');
921
+ }
839
922
  }
840
923
 
841
924
  PHP;
@@ -3,6 +3,10 @@
3
3
  namespace Nevela\Laravel\Http;
4
4
 
5
5
  use Illuminate\Foundation\Http\FormRequest;
6
+ use Illuminate\Support\Facades\DB;
7
+ use Illuminate\Support\Facades\Schema;
8
+ use Illuminate\Support\Facades\Validator as Validation;
9
+ use Illuminate\Validation\Rules\Unique;
6
10
  use Illuminate\Validation\Validator;
7
11
 
8
12
  /**
@@ -49,9 +53,113 @@ abstract class ResourceRequest extends FormRequest
49
53
  $validator->errors()->add((string) $key, 'Unknown field.');
50
54
  }
51
55
  }
56
+ self::explainTrashed($validator, $validator->getData());
52
57
  });
53
58
  }
54
59
 
60
+ /**
61
+ * A unique value can be held by a record nobody can see: one in the trash. "Already
62
+ * taken" would send someone looking through a list it isn't in, so say where it is.
63
+ *
64
+ * @param array<string, mixed> $data
65
+ * @return list<array{path: string, message: string}> The messages that were replaced
66
+ */
67
+ private static function explainTrashed(Validator $validator, array $data): array
68
+ {
69
+ $replaced = [];
70
+ foreach ($validator->failed() as $field => $rules) {
71
+ // What Laravel gives for a failed unique rule: the table and the column it looked in.
72
+ [$table, $column] = array_pad($rules['Unique'] ?? [], 2, null);
73
+ $value = $data[$field] ?? null;
74
+ if (! is_string($table) || ! is_string($column) || ! is_scalar($value) || $column === 'NULL') {
75
+ continue;
76
+ }
77
+ if (! Schema::hasColumn($table, 'deleted_at') || ! DB::table($table)->where($column, $value)->whereNotNull('deleted_at')->exists()) {
78
+ continue;
79
+ }
80
+ $message = 'This belongs to a record in the trash. Restore that record, or delete it for good to use the value again.';
81
+ $validator->errors()->forget($field);
82
+ $validator->errors()->add($field, $message);
83
+ $replaced[] = ['path' => (string) $field, 'message' => $message];
84
+ }
85
+
86
+ return $replaced;
87
+ }
88
+
89
+ /**
90
+ * Many rows, each checked by the rules for creating one.
91
+ *
92
+ * The same rules as a single create, on purpose: a second way into the table with its
93
+ * own idea of what is valid would let through a row the form then refuses to save.
94
+ * Rows are numbered from 1, as a person counts them.
95
+ *
96
+ * Two rows of one batch can't share a unique value either. The database would refuse
97
+ * the second, but only after the first was written and with no row number to show.
98
+ *
99
+ * @param list<mixed> $rows
100
+ * @return array{values: list<array<string, mixed>>, problems: list<array{row: int, issues: list<array{path: string, message: string}>}>}
101
+ */
102
+ public function many(array $rows): array
103
+ {
104
+ $rules = $this->rules();
105
+ $columns = $this->columns();
106
+ $known = array_keys($columns);
107
+ $computed = array_map(fn (string $field) => $field.'File', $known);
108
+ $unique = array_keys(array_filter($rules, fn ($rule) => is_array($rule) && array_filter($rule, fn ($one) => $one instanceof Unique) !== []));
109
+
110
+ $values = [];
111
+ $problems = [];
112
+ $taken = [];
113
+ foreach (array_values($rows) as $index => $row) {
114
+ $number = $index + 1;
115
+ if (! is_array($row) || array_is_list($row)) {
116
+ // An empty JSON object arrives as an empty list; anything else here is not fields at all.
117
+ $problems[] = ['row' => $number, 'issues' => [['path' => '', 'message' => $row === [] ? 'This row is empty.' : 'This row is not a record.']]];
118
+
119
+ continue;
120
+ }
121
+ $issues = [];
122
+ foreach (array_keys($row) as $key) {
123
+ if (! in_array($key, $known, true) && ! in_array($key, self::READ_ONLY, true) && ! in_array($key, $computed, true)) {
124
+ $issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
125
+ }
126
+ }
127
+ $validator = Validation::make($row, $rules);
128
+ if ($validator->fails()) {
129
+ self::explainTrashed($validator, $row);
130
+ array_push($issues, ...FlareErrors::issues($validator->errors()->toArray()));
131
+ }
132
+ foreach ($unique as $field) {
133
+ $value = $row[$field] ?? null;
134
+ if (! is_scalar($value) || (string) $value === '') {
135
+ continue;
136
+ }
137
+ $mark = $field."\0".$value;
138
+ if (isset($taken[$mark])) {
139
+ $issues[] = ['path' => $field, 'message' => "The same as row {$taken[$mark]}. Each one needs its own."];
140
+ } else {
141
+ $taken[$mark] = $number;
142
+ }
143
+ }
144
+ if ($issues !== []) {
145
+ $problems[] = ['row' => $number, 'issues' => $issues];
146
+
147
+ continue;
148
+ }
149
+
150
+ $validated = $validator->validated();
151
+ $mapped = [];
152
+ foreach ($columns as $field => $column) {
153
+ if (array_key_exists($field, $validated)) {
154
+ $mapped[$column] = $validated[$field];
155
+ }
156
+ }
157
+ $values[] = $mapped;
158
+ }
159
+
160
+ return ['values' => $values, 'problems' => $problems];
161
+ }
162
+
55
163
  /** Validated input keyed by column, ready for create()/update(). @return array<string, mixed> */
56
164
  public function values(): array
57
165
  {