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.
- package/index.mjs +1 -0
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +25 -3
- package/template/laravel/src/Access/Permissions.php +36 -5
- package/template/laravel/src/Access/SampleUsers.php +1 -3
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -0
- package/template/laravel/src/Console/GenerateCommand.php +13 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- package/template/laravel/src/Console/TrashCommand.php +8 -0
- package/template/laravel/src/Console/UserCommand.php +5 -1
- package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
- package/template/laravel/src/Http/Access/UsersController.php +2 -4
- package/template/laravel/src/Http/ActionController.php +222 -0
- package/template/laravel/src/Http/PublicController.php +210 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +151 -2
- package/template/laravel/src/Http/TrashController.php +18 -2
- package/template/laravel/src/Http/UploadController.php +42 -1
- package/template/laravel/src/Media/Uploads.php +111 -0
- package/template/laravel/src/Nevela.php +54 -2
- package/template/laravel/src/NevelaServiceProvider.php +17 -1
- package/template/laravel/src/Support/Action.php +168 -0
- package/template/laravel/src/Support/Adoption.php +352 -0
- package/template/laravel/src/Support/Descriptor.php +59 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +74 -3
- package/template/laravel/src/Support/Html.php +185 -0
- package/template/laravel/src/Support/PublicAccess.php +133 -0
- package/template/web/app/api/[...path]/route.ts +42 -9
- package/template/web/app/dashboard/actions.ts +34 -0
- package/template/web/app/globals.css +46 -0
- package/template/web/components/access/role-form.tsx +40 -5
- package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
- package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
- package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
- package/template/web/components/dashboard/fields/file-field.tsx +9 -5
- package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
- package/template/web/components/dashboard/record-actions.tsx +7 -2
- package/template/web/components/dashboard/record-detail.tsx +9 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- package/template/web/components/dashboard/resource-table.tsx +15 -2
- package/template/web/components/dashboard/row-actions.tsx +24 -0
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +66 -0
- package/template/web/lib/files.ts +14 -0
- package/template/web/lib/laravel.ts +60 -0
- package/template/web/lib/rich.ts +79 -0
- 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
|
-
/**
|
|
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);
|