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.
- package/index.mjs +25 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +66 -3
- package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
- package/template/laravel/resources/disposable-domains.txt +9880 -0
- package/template/laravel/src/Access/ClosedAccounts.php +2 -1
- package/template/laravel/src/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -0
- package/template/laravel/src/Console/BlogCommand.php +254 -0
- package/template/laravel/src/Console/DevCommand.php +9 -0
- package/template/laravel/src/Console/DigestCommand.php +28 -0
- package/template/laravel/src/Console/DisposableCommand.php +49 -0
- package/template/laravel/src/Console/GenerateCommand.php +38 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- package/template/laravel/src/Console/SetupCommand.php +15 -2
- package/template/laravel/src/Console/StatusCommand.php +14 -0
- package/template/laravel/src/Console/TrashCommand.php +17 -0
- package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
- package/template/laravel/src/Forms/Confirmation.php +88 -0
- package/template/laravel/src/Forms/Disposable.php +74 -0
- package/template/laravel/src/Forms/FormBlocks.php +76 -0
- package/template/laravel/src/Forms/FormGuard.php +359 -0
- package/template/laravel/src/Forms/OwnerDigest.php +63 -0
- package/template/laravel/src/Forms/PublicMail.php +217 -0
- package/template/laravel/src/Forms/Refusal.php +50 -0
- package/template/laravel/src/Generator/BlogPages.php +409 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
- package/template/laravel/src/Http/Access/RolesController.php +2 -1
- package/template/laravel/src/Http/Access/UsersController.php +2 -1
- package/template/laravel/src/Http/ActionController.php +223 -0
- package/template/laravel/src/Http/Auth/AccountController.php +6 -3
- package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
- package/template/laravel/src/Http/FormsController.php +45 -0
- package/template/laravel/src/Http/PublicController.php +294 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +193 -4
- package/template/laravel/src/Http/TrashController.php +19 -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 +83 -2
- package/template/laravel/src/NevelaServiceProvider.php +27 -2
- 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 +83 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +91 -3
- package/template/laravel/src/Support/Html.php +185 -0
- package/template/laravel/src/Support/Key.php +57 -0
- package/template/laravel/src/Support/ListQuery.php +29 -1
- package/template/laravel/src/Support/PublicAccess.php +208 -0
- package/template/laravel/src/Support/Slug.php +43 -0
- package/template/laravel/src/Trash/Trash.php +3 -1
- package/template/web/.env.example +4 -0
- package/template/web/app/api/[...path]/route.ts +42 -9
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +48 -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/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +33 -4
- package/template/web/components/dashboard/record-detail.tsx +10 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- package/template/web/components/dashboard/resource-form.tsx +19 -2
- package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
- package/template/web/components/dashboard/resource-table.tsx +46 -4
- package/template/web/components/dashboard/row-actions.tsx +56 -2
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- package/template/web/components/marketing/site-header.tsx +9 -2
- package/template/web/components/public-form-guard.tsx +125 -0
- package/template/web/hooks/use-public-form.ts +175 -0
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +105 -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
|
@@ -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
|
+
}
|