create-nevela 0.7.1 → 0.9.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.
Files changed (87) hide show
  1. package/index.mjs +25 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +66 -3
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Access/Permissions.php +36 -5
  9. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  10. package/template/laravel/src/Concerns/Trashable.php +23 -0
  11. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  12. package/template/laravel/src/Console/BlogCommand.php +254 -0
  13. package/template/laravel/src/Console/DevCommand.php +9 -0
  14. package/template/laravel/src/Console/DigestCommand.php +28 -0
  15. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +38 -3
  17. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  18. package/template/laravel/src/Console/SeedCommand.php +3 -1
  19. package/template/laravel/src/Console/SetupCommand.php +15 -2
  20. package/template/laravel/src/Console/StatusCommand.php +14 -0
  21. package/template/laravel/src/Console/TrashCommand.php +17 -0
  22. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  23. package/template/laravel/src/Forms/Confirmation.php +88 -0
  24. package/template/laravel/src/Forms/Disposable.php +74 -0
  25. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  26. package/template/laravel/src/Forms/FormGuard.php +359 -0
  27. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  28. package/template/laravel/src/Forms/PublicMail.php +217 -0
  29. package/template/laravel/src/Forms/Refusal.php +50 -0
  30. package/template/laravel/src/Generator/BlogPages.php +409 -0
  31. package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
  32. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  33. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  34. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  35. package/template/laravel/src/Http/ActionController.php +223 -0
  36. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  37. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  38. package/template/laravel/src/Http/FormsController.php +45 -0
  39. package/template/laravel/src/Http/PublicController.php +294 -0
  40. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  41. package/template/laravel/src/Http/ResourceRequest.php +193 -4
  42. package/template/laravel/src/Http/TrashController.php +19 -2
  43. package/template/laravel/src/Http/UploadController.php +42 -1
  44. package/template/laravel/src/Media/Uploads.php +111 -0
  45. package/template/laravel/src/Nevela.php +83 -2
  46. package/template/laravel/src/NevelaServiceProvider.php +27 -2
  47. package/template/laravel/src/Support/Action.php +168 -0
  48. package/template/laravel/src/Support/Adoption.php +352 -0
  49. package/template/laravel/src/Support/Descriptor.php +83 -4
  50. package/template/laravel/src/Support/Fake.php +15 -1
  51. package/template/laravel/src/Support/Field.php +91 -3
  52. package/template/laravel/src/Support/Html.php +185 -0
  53. package/template/laravel/src/Support/Key.php +57 -0
  54. package/template/laravel/src/Support/ListQuery.php +29 -1
  55. package/template/laravel/src/Support/PublicAccess.php +208 -0
  56. package/template/laravel/src/Support/Slug.php +43 -0
  57. package/template/laravel/src/Trash/Trash.php +3 -1
  58. package/template/web/.env.example +4 -0
  59. package/template/web/app/api/[...path]/route.ts +42 -9
  60. package/template/web/app/confirm/page.tsx +51 -0
  61. package/template/web/app/dashboard/actions.ts +48 -0
  62. package/template/web/app/globals.css +46 -0
  63. package/template/web/components/access/role-form.tsx +40 -5
  64. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  65. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  66. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  67. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  68. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  69. package/template/web/components/dashboard/form-activity.tsx +53 -0
  70. package/template/web/components/dashboard/record-actions.tsx +33 -4
  71. package/template/web/components/dashboard/record-detail.tsx +10 -2
  72. package/template/web/components/dashboard/resource-action.tsx +281 -0
  73. package/template/web/components/dashboard/resource-form.tsx +19 -2
  74. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  75. package/template/web/components/dashboard/resource-table.tsx +46 -4
  76. package/template/web/components/dashboard/row-actions.tsx +56 -2
  77. package/template/web/components/dashboard/table-selection.tsx +19 -1
  78. package/template/web/components/marketing/site-header.tsx +9 -2
  79. package/template/web/components/public-form-guard.tsx +125 -0
  80. package/template/web/hooks/use-public-form.ts +175 -0
  81. package/template/web/lib/access.ts +3 -1
  82. package/template/web/lib/dashboard.ts +10 -0
  83. package/template/web/lib/extras.ts +105 -0
  84. package/template/web/lib/files.ts +14 -0
  85. package/template/web/lib/laravel.ts +60 -0
  86. package/template/web/lib/rich.ts +79 -0
  87. package/template/web/package.json +1 -1
@@ -13,7 +13,11 @@ use Illuminate\Support\Facades\Route;
13
13
  use Illuminate\Support\ServiceProvider;
14
14
  use Nevela\Laravel\Access\Access;
15
15
  use Nevela\Laravel\Access\Permissions;
16
+ use Nevela\Laravel\Console\AdoptCommand;
17
+ use Nevela\Laravel\Console\BlogCommand;
16
18
  use Nevela\Laravel\Console\DevCommand;
19
+ use Nevela\Laravel\Console\DigestCommand;
20
+ use Nevela\Laravel\Console\DisposableCommand;
17
21
  use Nevela\Laravel\Console\GenerateCommand;
18
22
  use Nevela\Laravel\Console\MakeResourceCommand;
19
23
  use Nevela\Laravel\Console\SeedCommand;
@@ -28,10 +32,13 @@ use Nevela\Laravel\Http\Access\RolesAreSetUp;
28
32
  use Nevela\Laravel\Http\Access\RolesController;
29
33
  use Nevela\Laravel\Http\Access\UsersController;
30
34
  use Nevela\Laravel\Http\FlareErrors;
35
+ use Nevela\Laravel\Http\FormsController;
31
36
  use Nevela\Laravel\Http\Auth\AccountController;
32
37
  use Nevela\Laravel\Http\Auth\SecurityController;
33
38
  use Nevela\Laravel\Http\Auth\SignInController;
39
+ use Nevela\Laravel\Http\PublicThrottle;
34
40
  use Nevela\Laravel\Http\TokenController;
41
+ use Nevela\Laravel\Http\ActionController;
35
42
  use Nevela\Laravel\Http\TrashController;
36
43
  use Nevela\Laravel\Http\UploadController;
37
44
  use Nevela\Laravel\Media\Uploads;
@@ -58,9 +65,9 @@ final class NevelaServiceProvider extends ServiceProvider
58
65
 
59
66
  $this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
60
67
  $this->commands([
61
- MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
68
+ MakeResourceCommand::class, AdoptCommand::class, BlogCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
62
69
  UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class, SetupCommand::class,
63
- TrashCommand::class,
70
+ TrashCommand::class, DigestCommand::class, DisposableCommand::class,
64
71
  ]);
65
72
 
66
73
  // What has been in the trash too long is removed each night, wherever the app's
@@ -68,6 +75,8 @@ final class NevelaServiceProvider extends ServiceProvider
68
75
  // happens whenever someone opens the trash.
69
76
  $this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
70
77
  $schedule->command(TrashCommand::class)->dailyAt('03:40')->withoutOverlapping();
78
+ // The notices to the owner that were held back in a busy hour, as one email.
79
+ $schedule->command(DigestCommand::class)->hourly()->withoutOverlapping();
71
80
  });
72
81
  }
73
82
 
@@ -172,12 +181,21 @@ final class NevelaServiceProvider extends ServiceProvider
172
181
  'app' => Nevela::fingerprint(),
173
182
  ]))->name('nevela.ping');
174
183
 
184
+ Route::aliasMiddleware('nevela.throttle', PublicThrottle::class);
185
+
186
+ // What a resource's "public" section opens: no sign-in, and each route its own throttle.
187
+ if (is_file($open = base_path('routes/nevela-public.php'))) {
188
+ Route::prefix(trim($prefix.'/public', '/'))->middleware('api')->name('nevela.public.')->group($open);
189
+ }
190
+
175
191
  // Stored files. Open, like any image on a website: the key is the secret.
176
192
  Route::prefix($prefix)->middleware('api')->get('_nevela/files/{path}', [UploadController::class, 'show'])
177
193
  ->where('path', '.*')->name('nevela.files.show');
178
194
 
179
195
  Route::prefix($prefix)->middleware(config('nevela.middleware', ['api', 'auth:sanctum']))->name('nevela.')->group(function () {
180
196
  Route::put('_nevela/uploads/{resource}/{field}', [UploadController::class, 'store'])->name('uploads.store');
197
+ // A private file: only for someone who may view the record it belongs to.
198
+ Route::get('_nevela/private/{slug}/{id}/{field}', [UploadController::class, 'download'])->name('private.show');
181
199
 
182
200
  // The image profiles, for a client that wants to know which renditions exist.
183
201
  Route::get('_nevela/profiles', fn () => response()->json([
@@ -192,6 +210,9 @@ final class NevelaServiceProvider extends ServiceProvider
192
210
  Route::delete('_nevela/trash/{slug}/{id}', [TrashController::class, 'destroy'])->name('trash.destroy');
193
211
  Route::delete('_nevela/trash/{slug}', [TrashController::class, 'empty'])->name('trash.empty');
194
212
 
213
+ // A public form, from the inside: what it checks and what it has turned away.
214
+ Route::get('_nevela/forms/{slug}', [FormsController::class, 'show'])->name('forms.show');
215
+
195
216
  // Users and roles, for whoever holds the permissions to manage them.
196
217
  Route::middleware(RolesAreSetUp::class)->group(function () {
197
218
  Route::get('_nevela/users', [UsersController::class, 'index'])->name('users.index');
@@ -221,6 +242,10 @@ final class NevelaServiceProvider extends ServiceProvider
221
242
  'data' => array_map(fn ($d) => $d->toArray(), Nevela::all()),
222
243
  ]))->name('resources');
223
244
 
245
+ // A resource's own actions: on one record, and on the list or a selection from it.
246
+ Route::post('{slug}/_actions/{action}', [ActionController::class, 'collection'])->name('actions.collection');
247
+ Route::post('{slug}/{id}/_actions/{action}', [ActionController::class, 'record'])->name('actions.record');
248
+
224
249
  if (is_file($routes = base_path('routes/nevela.php'))) {
225
250
  require $routes;
226
251
  }
@@ -0,0 +1,168 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Illuminate\Validation\Rule;
6
+ use InvalidArgumentException;
7
+
8
+ /**
9
+ * Something that can be done to a record, or to a resource as a whole, beyond creating,
10
+ * editing and deleting it: accept an application, send an announcement.
11
+ *
12
+ * The descriptor says what it is called, where its button goes and what it asks for.
13
+ * What it does is a class of the app's own, written once: App\Nevela\Actions\<Resource>\<Action>.
14
+ * Between the two, Nevela does the route, the permission, the checking and the button.
15
+ */
16
+ final class Action
17
+ {
18
+ /** Where its button is: on one record, or on the list as a whole. */
19
+ public const ON = ['record', 'collection'];
20
+
21
+ /** Names that already mean something for every resource. */
22
+ public const RESERVED = ['create', 'view', 'edit', 'delete', 'update', 'restore', 'list', 'read'];
23
+
24
+ /** What an action can ask for. A picture or a relation is a record's business, not a button's. */
25
+ public const KINDS = ['string', 'text', 'int', 'float', 'boolean', 'date', 'datetime', 'enum'];
26
+
27
+ /**
28
+ * @param string $name camelCase, as in the descriptor and the address: accept, sendAnnouncement
29
+ * @param string|null $confirm A question to ask before it runs
30
+ * @param array<string, scalar|null> $when Field values a record must have for the action to apply to it
31
+ * @param array<string, Field> $fields What it asks the person for, in a sheet
32
+ * @param bool $many A record action that can also be run on a selection of rows
33
+ */
34
+ public function __construct(
35
+ public readonly string $name,
36
+ public readonly string $label,
37
+ public readonly string $on = 'record',
38
+ public readonly ?string $icon = null,
39
+ public readonly ?string $confirm = null,
40
+ public readonly array $when = [],
41
+ public readonly array $fields = [],
42
+ public readonly bool $many = false,
43
+ ) {
44
+ if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $name)) {
45
+ throw new InvalidArgumentException("Action \"{$name}\" must be camelCase, e.g. accept or sendAnnouncement.");
46
+ }
47
+ if (in_array(Naming::snake($name), self::RESERVED, true)) {
48
+ throw new InvalidArgumentException("\"{$name}\" is something every resource does already. Call the action something else.");
49
+ }
50
+ if (! in_array($on, self::ON, true)) {
51
+ throw new InvalidArgumentException("Action \"{$name}\" is on \"{$on}\". Use record or collection.");
52
+ }
53
+ if ($on === 'collection' && ($when !== [] || $many)) {
54
+ throw new InvalidArgumentException("Action \"{$name}\" is on the collection, so it has no one record to check \"when\" against or to select.");
55
+ }
56
+ foreach ($when as $field => $value) {
57
+ if (! is_scalar($value) && $value !== null) {
58
+ throw new InvalidArgumentException("Action \"{$name}\": \"when\" compares \"{$field}\" with one value, or with null.");
59
+ }
60
+ }
61
+ foreach ($fields as $field) {
62
+ if (! in_array($field->kind, self::KINDS, true)) {
63
+ throw new InvalidArgumentException("Action \"{$name}\" can't ask for \"{$field->name}\": a {$field->kind} isn't something a button asks for. Use: ".implode(', ', self::KINDS).'.');
64
+ }
65
+ }
66
+ }
67
+
68
+ /** @param array<string, mixed> $data */
69
+ public static function fromArray(string $name, array $data): self
70
+ {
71
+ $fields = [];
72
+ foreach ((array) ($data['fields'] ?? []) as $key => $field) {
73
+ $fields[(string) $key] = Field::fromArray((string) $key, (array) $field);
74
+ }
75
+
76
+ return new self(
77
+ $name,
78
+ (string) ($data['label'] ?? Naming::humanize($name)),
79
+ (string) ($data['on'] ?? 'record'),
80
+ $data['icon'] ?? null,
81
+ $data['confirm'] ?? null,
82
+ (array) ($data['when'] ?? []),
83
+ $fields,
84
+ (bool) ($data['many'] ?? false),
85
+ );
86
+ }
87
+
88
+ /** @return array<string, mixed> */
89
+ public function toArray(): array
90
+ {
91
+ return array_filter([
92
+ 'label' => $this->label,
93
+ 'icon' => $this->icon,
94
+ 'on' => $this->on,
95
+ 'confirm' => $this->confirm,
96
+ 'when' => $this->when ?: null,
97
+ 'fields' => $this->fields ? array_map(fn (Field $f) => $f->toArray(), $this->fields) : null,
98
+ 'many' => $this->many ?: null,
99
+ ], fn ($v) => $v !== null);
100
+ }
101
+
102
+ /** The second half of its permission: "send_announcement" in "newsletter_subscriptions.send_announcement". */
103
+ public function key(): string
104
+ {
105
+ return Naming::snake($this->name);
106
+ }
107
+
108
+ /** The class that does it, without its namespace: SendAnnouncement. */
109
+ public function className(): string
110
+ {
111
+ return Naming::pascal($this->name);
112
+ }
113
+
114
+ /**
115
+ * Whether it applies to a record with these values, by field name.
116
+ *
117
+ * @param array<string, mixed> $values
118
+ */
119
+ public function applies(array $values): bool
120
+ {
121
+ foreach ($this->when as $field => $wanted) {
122
+ $value = $values[$field] ?? null;
123
+ $same = match (true) {
124
+ $wanted === null => $value === null,
125
+ $value === null => false,
126
+ // A yes/no column comes back from some databases as 1 and 0.
127
+ is_bool($wanted) => (bool) $value === $wanted,
128
+ default => (string) $value === (string) $wanted,
129
+ };
130
+ if (! $same) {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ return true;
136
+ }
137
+
138
+ /**
139
+ * The rules its fields are checked by: the ones a resource's field of the same kind has.
140
+ *
141
+ * @return array<string, list<mixed>>
142
+ */
143
+ public function rules(): array
144
+ {
145
+ $rules = [];
146
+ foreach ($this->fields as $field) {
147
+ $rules[$field->name] = [$field->required ? 'required' : 'nullable', ...match (true) {
148
+ $field->format === 'email' => ['email', 'max:'.($field->maxLength ?? 255)],
149
+ $field->format === 'url' => ['url', 'max:'.($field->maxLength ?? 2048)],
150
+ $field->format === 'tel' => ['string', 'max:'.($field->maxLength ?? 32)],
151
+ $field->format === 'slug' => ['string', 'alpha_dash', 'max:'.($field->maxLength ?? 255)],
152
+ $field->format === 'color' => ['string', 'regex:/^#[0-9a-fA-F]{6}$/'],
153
+ $field->kind === 'string' => ['string', 'max:'.($field->maxLength ?? 255)],
154
+ $field->kind === 'text' => ['string', 'max:'.($field->maxLength ?? 65535)],
155
+ $field->kind === 'int' && $field->format === 'percent' => ['integer', 'between:0,100'],
156
+ $field->kind === 'int' => ['integer'],
157
+ $field->kind === 'float' && $field->format === 'percent' => ['numeric', 'between:0,100'],
158
+ $field->kind === 'float' => ['numeric'],
159
+ $field->kind === 'boolean' => ['boolean'],
160
+ $field->kind === 'date' => ['date_format:Y-m-d'],
161
+ $field->kind === 'datetime' => ['date'],
162
+ $field->kind === 'enum' => [Rule::in($field->options)],
163
+ }];
164
+ }
165
+
166
+ return $rules;
167
+ }
168
+ }
@@ -0,0 +1,352 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Illuminate\Support\Facades\DB;
6
+ use Illuminate\Support\Facades\Schema;
7
+ use Illuminate\Support\Str;
8
+ use InvalidArgumentException;
9
+
10
+ /**
11
+ * A descriptor for a table that is already there, read from the table itself.
12
+ *
13
+ * `php artisan nevela:adopt posts` is for an app that had its tables before it had Nevela.
14
+ * Nothing about the table is changed by reading it, and what can't be said in a descriptor
15
+ * is left out of it and listed: a column Nevela doesn't know about stays in the table,
16
+ * untouched by the API, for the app's own code to use.
17
+ *
18
+ * Types are taken as they are and no tighter. An `email` column is adopted as a string,
19
+ * not as an email, because every value in it was valid when it was saved and a stricter
20
+ * rule would refuse to save an old record again. Tightening is for whoever knows the data.
21
+ */
22
+ final class Adoption
23
+ {
24
+ private const INTEGERS = ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'int2', 'int4', 'int8', 'serial', 'bigserial', 'year'];
25
+
26
+ private const DECIMALS = ['decimal', 'numeric', 'float', 'double', 'real', 'float4', 'float8', 'double precision', 'money'];
27
+
28
+ private const STRINGS = ['varchar', 'char', 'bpchar', 'nvarchar', 'nchar', 'character varying', 'character', 'string'];
29
+
30
+ private const DATETIMES = ['datetime', 'timestamp', 'timestamptz', 'datetime2', 'datetimeoffset', 'smalldatetime'];
31
+
32
+ /** Columns every resource has already, by another name. */
33
+ private const OWN = ['id', 'created_at', 'updated_at', 'deleted_at'];
34
+
35
+ /**
36
+ * @param array<string, Field> $fields The fields, keyed by the column each was read from
37
+ * @param list<array{column: string, why: string}> $left Columns that are not in the descriptor
38
+ * @param list<string> $notes What whoever adopts the table should know
39
+ */
40
+ private function __construct(
41
+ public readonly Descriptor $descriptor,
42
+ public readonly array $fields,
43
+ public readonly array $left,
44
+ public readonly array $notes,
45
+ ) {}
46
+
47
+ /**
48
+ * Read a table from the database.
49
+ *
50
+ * @param list<Descriptor> $resources The resources the app has: a foreign key to one of their tables is a relation
51
+ *
52
+ * @throws InvalidArgumentException when the table can't be a resource, with the reason
53
+ */
54
+ public static function of(string $table, array $resources = [], ?string $name = null, ?string $icon = null, ?string $group = null): self
55
+ {
56
+ if (! Schema::hasTable($table)) {
57
+ throw new InvalidArgumentException("There is no table called {$table}.");
58
+ }
59
+
60
+ $columns = Schema::getColumns($table);
61
+
62
+ return self::describe($table, $columns, Schema::getIndexes($table), Schema::getForeignKeys($table), $resources, $name, $icon, $group, self::longest($table, $columns));
63
+ }
64
+
65
+ /**
66
+ * The longest value in each string column that has no length of its own.
67
+ *
68
+ * PostgreSQL's `varchar` can be declared without one, and SQLite keeps none at all,
69
+ * so nothing about such a column says how long its values may be. The values do.
70
+ *
71
+ * @param list<array<string, mixed>> $columns
72
+ * @return array<string, int> by column
73
+ */
74
+ private static function longest(string $table, array $columns): array
75
+ {
76
+ $unbounded = [];
77
+ foreach ($columns as $column) {
78
+ if (in_array(strtolower((string) ($column['type_name'] ?? '')), self::STRINGS, true) && ! str_contains((string) ($column['type'] ?? ''), '(')) {
79
+ $unbounded[] = (string) $column['name'];
80
+ }
81
+ }
82
+ if ($unbounded === []) {
83
+ return [];
84
+ }
85
+ try {
86
+ $grammar = DB::connection()->getQueryGrammar();
87
+ $row = (array) DB::table($table)->selectRaw(implode(', ', array_map(fn (string $column) => 'max(length('.$grammar->wrap($column).')) as '.$grammar->wrap($column), $unbounded)))->first();
88
+ } catch (\Throwable) {
89
+ return []; // a database that measures some other way: the usual length, as for any string
90
+ }
91
+
92
+ return array_map(fn ($length) => (int) $length, $row);
93
+ }
94
+
95
+ /**
96
+ * The same, from what Laravel's schema builder says about a table: getColumns(),
97
+ * getIndexes() and getForeignKeys().
98
+ *
99
+ * @param list<array<string, mixed>> $columns
100
+ * @param list<array<string, mixed>> $indexes
101
+ * @param list<array<string, mixed>> $foreignKeys
102
+ * @param list<Descriptor> $resources
103
+ * @param array<string, int> $longest The longest value in a string column that declares no length, by column
104
+ *
105
+ * @throws InvalidArgumentException
106
+ */
107
+ public static function describe(string $table, array $columns, array $indexes, array $foreignKeys, array $resources = [], ?string $name = null, ?string $icon = null, ?string $group = null, array $longest = []): self
108
+ {
109
+ $name = Naming::pascal($name ?? Str::singular($table));
110
+ $byName = [];
111
+ foreach ($columns as $column) {
112
+ $byName[(string) $column['name']] = $column;
113
+ }
114
+
115
+ $idType = self::idType($table, $byName, $indexes);
116
+ $missing = array_values(array_diff(['created_at', 'updated_at'], array_keys($byName)));
117
+ if ($missing !== []) {
118
+ throw new InvalidArgumentException("{$table} has no ".implode(' or ', $missing).'. Nevela sorts and counts records by when they were made and changed: add the columns with a migration ($table->timestamps()), then adopt it.');
119
+ }
120
+
121
+ $unique = [];
122
+ foreach ($indexes as $index) {
123
+ if (($index['unique'] ?? false) && ! ($index['primary'] ?? false) && count($index['columns'] ?? []) === 1) {
124
+ $unique[(string) $index['columns'][0]] = true;
125
+ }
126
+ }
127
+ // column => the table it points at, for a foreign key on that one column to an id.
128
+ $parents = [];
129
+ foreach ($foreignKeys as $key) {
130
+ if (count($key['columns'] ?? []) === 1 && ($key['foreign_columns'] ?? []) === ['id']) {
131
+ $parents[(string) $key['columns'][0]] = (string) $key['foreign_table'];
132
+ }
133
+ }
134
+ $tables = [$table => $name];
135
+ foreach ($resources as $resource) {
136
+ $tables[$resource->table] ??= $resource->name;
137
+ }
138
+
139
+ $fields = [];
140
+ $left = [];
141
+ $notes = [];
142
+ foreach ($byName as $column => $info) {
143
+ if (in_array($column, self::OWN, true)) {
144
+ continue;
145
+ }
146
+ $required = ! ($info['nullable'] ?? false);
147
+ $leave = function (string $why) use (&$left, &$notes, $column, $info, $required) {
148
+ $left[] = ['column' => $column, 'why' => $why];
149
+ if ($required && ($info['default'] ?? null) === null && ! ($info['auto_increment'] ?? false) && ($info['generation'] ?? null) === null) {
150
+ $notes[] = "{$column} has to have a value and has no default, and it isn't in the descriptor, so creating a record from the dashboard will fail. Give the column a default, or make it nullable.";
151
+ }
152
+ };
153
+
154
+ $field = Naming::camel($column);
155
+ if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $field) || Naming::snake($field) !== $column || in_array($field, Field::RESERVED, true)) {
156
+ $leave("its name can't be written as a field");
157
+
158
+ continue;
159
+ }
160
+ if (($info['generation'] ?? null) !== null) {
161
+ $leave('the database works it out');
162
+
163
+ continue;
164
+ }
165
+ if (preg_match('/(^|_)(password|secret|token)$/', $column)) {
166
+ $leave('it looks like a secret');
167
+
168
+ continue;
169
+ }
170
+
171
+ if (isset($parents[$column])) {
172
+ $target = $tables[$parents[$column]] ?? null;
173
+ if ($target !== null && str_ends_with($column, '_id')) {
174
+ $fields[$column] = new Field($field, 'belongsTo', required: $required, unique: isset($unique[$column]), target: $target);
175
+
176
+ continue;
177
+ }
178
+ $notes[] = $target === null
179
+ ? "{$column} points at {$parents[$column]}, which isn't a resource, so it is adopted as a plain value. Adopt {$parents[$column]} first and it becomes a relation."
180
+ : "{$column} points at {$parents[$column]}, but a relation's column ends in _id, so it is adopted as a plain value.";
181
+ }
182
+
183
+ $kind = self::kind($info);
184
+ if ($kind === null) {
185
+ $leave(strtolower((string) ($info['type_name'] ?? 'this type')).' has no field kind');
186
+
187
+ continue;
188
+ }
189
+ // No length of its own, and more in it already than the usual one allows.
190
+ if ($kind['kind'] === 'string' && ! isset($kind['maxLength']) && ($longest[$column] ?? 0) > 255) {
191
+ $kind['maxLength'] = max(65_535, $longest[$column]);
192
+ $notes[] = "{$column} declares no length and holds values longer than 255 characters, so it is adopted with room for ".number_format($kind['maxLength']).'.';
193
+ }
194
+ $default = isset($unique[$column]) ? null : self::default($kind, $info['default'] ?? null);
195
+ try {
196
+ $fields[$column] = new Field($field, $kind['kind'], required: $required, unique: isset($unique[$column]), options: $kind['options'] ?? [], maxLength: $kind['maxLength'] ?? null, default: $default);
197
+ } catch (InvalidArgumentException $e) {
198
+ $leave($e->getMessage());
199
+ }
200
+ }
201
+ if ($fields === []) {
202
+ throw new InvalidArgumentException("{$table} has no column that can be a field.");
203
+ }
204
+ // The app's own soft deletes become Nevela's trash, which doesn't keep things for ever.
205
+ if (isset($byName['deleted_at'])) {
206
+ $notes[] = "{$table} has a deleted_at column already, so the rows it marks as deleted are in Nevela's trash from the start, and the trash removes for good what has been in it longer than trash.days (30 unless you changed it). If those rows are to be kept, set trash.days to null in config/nevela.php before anyone opens the Trash page.";
207
+ }
208
+
209
+ $keyed = [];
210
+ foreach ($fields as $field) {
211
+ $keyed[$field->name] = $field;
212
+ }
213
+ $base = Descriptor::make($name, $keyed);
214
+
215
+ return new self(
216
+ new Descriptor($name, $keyed, $table, $base->slug, $base->label, $base->pluralLabel, $icon, $group, null, $idType, true),
217
+ $fields,
218
+ $left,
219
+ $notes,
220
+ );
221
+ }
222
+
223
+ /**
224
+ * What the table's ids are. A resource has one id column, called id, holding integers or UUIDs.
225
+ *
226
+ * @param array<string, array<string, mixed>> $columns
227
+ * @param list<array<string, mixed>> $indexes
228
+ */
229
+ private static function idType(string $table, array $columns, array $indexes): string
230
+ {
231
+ $primary = null;
232
+ foreach ($indexes as $index) {
233
+ if ($index['primary'] ?? false) {
234
+ $primary = array_values($index['columns'] ?? []);
235
+ }
236
+ }
237
+ $id = $columns['id'] ?? null;
238
+ if ($id === null || ($primary !== null && $primary !== ['id']) || ($primary === null && ! ($id['auto_increment'] ?? false))) {
239
+ throw new InvalidArgumentException("{$table} can't be a resource: a resource's primary key is one column called id.");
240
+ }
241
+ $type = strtolower((string) ($id['type_name'] ?? ''));
242
+ $full = strtolower((string) ($id['type'] ?? $type));
243
+
244
+ return match (true) {
245
+ in_array($type, self::INTEGERS, true) => 'int',
246
+ in_array($type, ['uuid', 'uniqueidentifier'], true), in_array($type, self::STRINGS, true) && str_contains($full, '(36)') => 'uuid',
247
+ default => throw new InvalidArgumentException("{$table} can't be a resource: its ids are neither integers nor UUIDs."),
248
+ };
249
+ }
250
+
251
+ /**
252
+ * The field kind a column is, or null when there isn't one for it.
253
+ *
254
+ * @param array<string, mixed> $column
255
+ * @return array{kind: string, maxLength?: int, options?: list<string>}|null
256
+ */
257
+ private static function kind(array $column): ?array
258
+ {
259
+ $type = strtolower((string) ($column['type_name'] ?? ''));
260
+ $full = strtolower((string) ($column['type'] ?? $type));
261
+ $length = preg_match('/\((\d+)\)/', $full, $m) ? (int) $m[1] : null;
262
+
263
+ return match (true) {
264
+ in_array($type, ['bool', 'boolean', 'bit'], true), $type === 'tinyint' && $length === 1 => ['kind' => 'boolean'],
265
+ in_array($type, self::INTEGERS, true) => ['kind' => 'int'],
266
+ in_array($type, self::DECIMALS, true) => ['kind' => 'float'],
267
+ $type === 'date' => ['kind' => 'date'],
268
+ in_array($type, self::DATETIMES, true) => ['kind' => 'datetime'],
269
+ // nvarchar(max) is SQL Server's long text.
270
+ in_array($type, self::STRINGS, true) && str_contains($full, '(max)') => ['kind' => 'text'],
271
+ // Its own length where it has one that isn't the usual: a value that fit the column has to fit the rule.
272
+ in_array($type, self::STRINGS, true) => ['kind' => 'string'] + ($length !== null && $length !== 255 ? ['maxLength' => $length] : []),
273
+ in_array($type, ['uuid', 'uniqueidentifier'], true) => ['kind' => 'string', 'maxLength' => 36],
274
+ $type === 'tinytext' => ['kind' => 'string'],
275
+ in_array($type, ['text', 'ntext'], true) => ['kind' => 'text'],
276
+ $type === 'mediumtext' => ['kind' => 'text', 'maxLength' => 16_777_215],
277
+ $type === 'longtext' => ['kind' => 'text', 'maxLength' => 4_294_967_295],
278
+ $type === 'enum' => self::choices((string) ($column['type'] ?? '')),
279
+ default => null,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * A column's default, as the field's: 'pending', 0, false. Each database writes it its
285
+ * own way ('pending'::character varying), and one that is worked out when a row is
286
+ * made (CURRENT_TIMESTAMP, nextval(…)) is the column's business and is left there.
287
+ *
288
+ * @param array{kind: string, options?: list<string>} $kind
289
+ */
290
+ private static function default(array $kind, mixed $written): string|int|float|bool|null
291
+ {
292
+ if (! is_scalar($written) || (string) $written === '') {
293
+ return null;
294
+ }
295
+ $text = trim((string) $written);
296
+ // PostgreSQL adds what type it is; SQL Server wraps it in brackets.
297
+ $text = (string) preg_replace('/::[a-z_ ]+(\([0-9, ]*\))?(\[\])?$/i', '', $text);
298
+ while (str_starts_with($text, '(') && str_ends_with($text, ')')) {
299
+ $text = substr($text, 1, -1);
300
+ }
301
+ $quoted = strlen($text) >= 2 && $text[0] === "'" && str_ends_with($text, "'");
302
+ $value = $quoted ? str_replace("''", "'", substr($text, 1, -1)) : $text;
303
+ if (! $quoted && (strcasecmp($value, 'null') === 0 || preg_match('/[()]|^current_/i', $value))) {
304
+ return null;
305
+ }
306
+
307
+ return match ($kind['kind']) {
308
+ 'string', 'text' => $value === '' ? null : $value,
309
+ 'enum' => in_array($value, $kind['options'] ?? [], true) ? $value : null,
310
+ 'int' => filter_var($value, FILTER_VALIDATE_INT) === false ? null : (int) $value,
311
+ 'float' => is_numeric($value) ? (float) $value : null,
312
+ 'boolean' => match (strtolower($value)) {
313
+ '1', 'true', 't' => true,
314
+ '0', 'false', 'f' => false,
315
+ default => null,
316
+ },
317
+ default => null,
318
+ };
319
+ }
320
+
321
+ /**
322
+ * A MySQL enum column: its values, where they are ones an enum field can have.
323
+ *
324
+ * @return array{kind: string, options?: list<string>}
325
+ */
326
+ private static function choices(string $type): array
327
+ {
328
+ preg_match_all("/'((?:[^']|'')*)'/", $type, $m);
329
+ $options = $m[1];
330
+ foreach ($options as $option) {
331
+ if (! preg_match('/^[A-Za-z0-9_-]+$/', $option)) {
332
+ return ['kind' => 'string'];
333
+ }
334
+ }
335
+
336
+ return $options === [] ? ['kind' => 'string'] : ['kind' => 'enum', 'options' => $options];
337
+ }
338
+
339
+ /** One field as it is written after --fields, e.g. "excerpt:string(500)?". */
340
+ public static function spec(Field $field): string
341
+ {
342
+ $type = match (true) {
343
+ $field->kind === 'belongsTo' => "belongsTo({$field->target})",
344
+ $field->kind === 'enum' => 'enum('.implode('|', $field->options).')',
345
+ $field->maxLength !== null => "{$field->kind}({$field->maxLength})",
346
+ default => $field->kind,
347
+ };
348
+ $default = $field->default === null ? '' : '='.(is_bool($field->default) ? ($field->default ? 'true' : 'false') : $field->default);
349
+
350
+ return $type.($field->required ? '' : '?').($field->unique ? '!' : '').$default;
351
+ }
352
+ }