create-nevela 0.7.0 → 0.8.0

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 (53) hide show
  1. package/index.mjs +1 -0
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +25 -3
  5. package/template/laravel/src/Access/Permissions.php +36 -5
  6. package/template/laravel/src/Access/SampleUsers.php +1 -3
  7. package/template/laravel/src/Concerns/Trashable.php +23 -0
  8. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  9. package/template/laravel/src/Console/GenerateCommand.php +13 -3
  10. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  11. package/template/laravel/src/Console/SeedCommand.php +3 -1
  12. package/template/laravel/src/Console/TrashCommand.php +8 -0
  13. package/template/laravel/src/Console/UserCommand.php +5 -1
  14. package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
  15. package/template/laravel/src/Http/Access/UsersController.php +2 -4
  16. package/template/laravel/src/Http/ActionController.php +222 -0
  17. package/template/laravel/src/Http/PublicController.php +210 -0
  18. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  19. package/template/laravel/src/Http/ResourceRequest.php +151 -2
  20. package/template/laravel/src/Http/TrashController.php +18 -2
  21. package/template/laravel/src/Http/UploadController.php +42 -1
  22. package/template/laravel/src/Media/Uploads.php +111 -0
  23. package/template/laravel/src/Nevela.php +54 -2
  24. package/template/laravel/src/NevelaServiceProvider.php +17 -1
  25. package/template/laravel/src/Support/Action.php +168 -0
  26. package/template/laravel/src/Support/Adoption.php +352 -0
  27. package/template/laravel/src/Support/Descriptor.php +59 -4
  28. package/template/laravel/src/Support/Fake.php +15 -1
  29. package/template/laravel/src/Support/Field.php +74 -3
  30. package/template/laravel/src/Support/Html.php +185 -0
  31. package/template/laravel/src/Support/PublicAccess.php +133 -0
  32. package/template/web/app/api/[...path]/route.ts +42 -9
  33. package/template/web/app/dashboard/actions.ts +34 -0
  34. package/template/web/app/globals.css +46 -0
  35. package/template/web/components/access/role-form.tsx +40 -5
  36. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  37. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  38. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  39. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  40. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  41. package/template/web/components/dashboard/record-actions.tsx +7 -2
  42. package/template/web/components/dashboard/record-detail.tsx +9 -2
  43. package/template/web/components/dashboard/resource-action.tsx +281 -0
  44. package/template/web/components/dashboard/resource-table.tsx +15 -2
  45. package/template/web/components/dashboard/row-actions.tsx +24 -0
  46. package/template/web/components/dashboard/table-selection.tsx +19 -1
  47. package/template/web/lib/access.ts +3 -1
  48. package/template/web/lib/dashboard.ts +10 -0
  49. package/template/web/lib/extras.ts +66 -0
  50. package/template/web/lib/files.ts +14 -0
  51. package/template/web/lib/laravel.ts +60 -0
  52. package/template/web/lib/rich.ts +79 -0
  53. package/template/web/package.json +1 -1
@@ -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
+ }
@@ -12,7 +12,16 @@ use JsonException;
12
12
  */
13
13
  final class Descriptor
14
14
  {
15
- /** @param array<string, Field> $fields keyed by field name, in declaration order */
15
+ /** What a record's id can be: a UUID, which is what Nevela makes, or the auto-incrementing integer of `$table->id()`. */
16
+ public const ID_TYPES = ['uuid', 'int'];
17
+
18
+ /**
19
+ * @param array<string, Field> $fields keyed by field name, in declaration order
20
+ * @param string $idType "uuid" or "int". Chosen when the resource is made: changing it later means migrating the table yourself.
21
+ * @param bool $adopted The table was there before Nevela, so no migration is written to create it
22
+ * @param array<string, Action> $actions What can be done to a record or to the list beyond create, edit and delete, by name
23
+ * @param PublicAccess|null $public What of it anyone may read or create without signing in. Nothing, when null.
24
+ */
16
25
  public function __construct(
17
26
  public readonly string $name,
18
27
  public readonly array $fields,
@@ -23,20 +32,41 @@ final class Descriptor
23
32
  public readonly ?string $icon = null,
24
33
  public readonly ?string $group = null,
25
34
  public readonly ?string $titleField = null,
35
+ public readonly string $idType = 'uuid',
36
+ public readonly bool $adopted = false,
37
+ public readonly array $actions = [],
38
+ public readonly ?PublicAccess $public = null,
26
39
  ) {
27
40
  if (! preg_match('/^[A-Z][A-Za-z0-9]*$/', $name)) {
28
41
  throw new InvalidArgumentException('Resource name must be PascalCase, e.g. Product or BlogPost.');
29
42
  }
43
+ if (! in_array($idType, self::ID_TYPES, true)) {
44
+ throw new InvalidArgumentException("\"{$idType}\" is not an id type. Use uuid or int.");
45
+ }
30
46
  if ($fields === []) {
31
47
  throw new InvalidArgumentException("Resource {$name} needs at least one field.");
32
48
  }
33
49
  if ($titleField !== null && ! isset($fields[$titleField])) {
34
50
  throw new InvalidArgumentException("titleField \"{$titleField}\" is not a field of {$name}.");
35
51
  }
52
+ $keys = [];
53
+ foreach ($actions as $action) {
54
+ foreach (array_keys($action->when) as $field) {
55
+ if (! isset($fields[$field])) {
56
+ throw new InvalidArgumentException("Action \"{$action->name}\" of {$name} depends on \"{$field}\", which is not one of its fields.");
57
+ }
58
+ }
59
+ // sendNow and send_now would be the same permission.
60
+ if (isset($keys[$action->key()])) {
61
+ throw new InvalidArgumentException("{$name} has two actions that come to \"{$action->key()}\".");
62
+ }
63
+ $keys[$action->key()] = true;
64
+ }
65
+ $public?->check($name, $fields);
36
66
  }
37
67
 
38
68
  /** Build from a name and a `--fields` string: "name:string, price:money, status:enum(draft|live)?". */
39
- public static function fromSpec(string $name, string $spec, ?string $icon = null, ?string $group = null): self
69
+ public static function fromSpec(string $name, string $spec, ?string $icon = null, ?string $group = null, string $idType = 'uuid'): self
40
70
  {
41
71
  $fields = [];
42
72
  foreach (array_filter(array_map('trim', self::splitSpec($spec))) as $token) {
@@ -47,11 +77,11 @@ final class Descriptor
47
77
  $fields[$field->name] = $field;
48
78
  }
49
79
 
50
- return self::make(Naming::pascal($name), $fields, icon: $icon, group: $group);
80
+ return self::make(Naming::pascal($name), $fields, icon: $icon, group: $group, idType: $idType);
51
81
  }
52
82
 
53
83
  /** @param array<string, Field> $fields */
54
- public static function make(string $name, array $fields, ?string $icon = null, ?string $group = null, ?string $titleField = null): self
84
+ public static function make(string $name, array $fields, ?string $icon = null, ?string $group = null, ?string $titleField = null, string $idType = 'uuid'): self
55
85
  {
56
86
  $plural = Naming::plural($name);
57
87
 
@@ -65,6 +95,7 @@ final class Descriptor
65
95
  $icon,
66
96
  $group,
67
97
  $titleField,
98
+ $idType,
68
99
  );
69
100
  }
70
101
 
@@ -77,6 +108,10 @@ final class Descriptor
77
108
  $fields[$key] = Field::fromArray($key, (array) $field);
78
109
  }
79
110
  $base = self::make($name, $fields);
111
+ $actions = [];
112
+ foreach ((array) ($data['actions'] ?? []) as $key => $action) {
113
+ $actions[(string) $key] = Action::fromArray((string) $key, (array) $action);
114
+ }
80
115
 
81
116
  return new self(
82
117
  $name,
@@ -88,6 +123,10 @@ final class Descriptor
88
123
  $data['icon'] ?? null,
89
124
  $data['group'] ?? null,
90
125
  $data['titleField'] ?? null,
126
+ (string) ($data['id'] ?? 'uuid'),
127
+ (bool) ($data['adopted'] ?? false),
128
+ $actions,
129
+ isset($data['public']) ? PublicAccess::fromArray((array) $data['public']) : null,
91
130
  );
92
131
  }
93
132
 
@@ -107,6 +146,9 @@ final class Descriptor
107
146
  '$schema' => 'https://nevela.dev/schema/resource.v1.json',
108
147
  'name' => $this->name,
109
148
  'table' => $this->table,
149
+ // Left out when they are what every descriptor written before them means.
150
+ 'id' => $this->idType === 'uuid' ? null : $this->idType,
151
+ 'adopted' => $this->adopted ?: null,
110
152
  'slug' => $this->slug,
111
153
  'label' => $this->label,
112
154
  'pluralLabel' => $this->pluralLabel,
@@ -114,6 +156,8 @@ final class Descriptor
114
156
  'group' => $this->group,
115
157
  'titleField' => $this->titleField,
116
158
  'fields' => array_map(fn (Field $f) => $f->toArray(), $this->fields),
159
+ 'actions' => $this->actions ? array_map(fn (Action $a) => $a->toArray(), $this->actions) : null,
160
+ 'public' => $this->public?->toArray(),
117
161
  ], fn ($v) => $v !== null);
118
162
  }
119
163
 
@@ -137,6 +181,17 @@ final class Descriptor
137
181
  return 'id';
138
182
  }
139
183
 
184
+ /**
185
+ * This resource with only some of its fields: what a list of it is sorted, filtered
186
+ * and searched by when the list is the public one.
187
+ *
188
+ * @param list<string> $fields
189
+ */
190
+ public function only(array $fields): self
191
+ {
192
+ return new self($this->name, array_intersect_key($this->fields, array_flip($fields)), $this->table, $this->slug, $this->label, $this->pluralLabel, $this->icon, $this->group, null, $this->idType, $this->adopted);
193
+ }
194
+
140
195
  public function variable(): string
141
196
  {
142
197
  return Naming::camel($this->name);