@flarum/cli 3.0.11 → 3.0.13

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 (76) hide show
  1. package/README.md +33 -1
  2. package/bin/libgit2.1.5.dylib +0 -0
  3. package/bin/splitsh-lite-darwin-arm64 +0 -0
  4. package/boilerplate/skeleton/extension/composer.json +4 -0
  5. package/boilerplate/skeleton/extension/js/package.json +5 -5
  6. package/boilerplate/skeleton/extension/js/tsconfig.json +3 -0
  7. package/boilerplate/skeleton/extension/js/tsconfig.test.json +1 -0
  8. package/boilerplate/skeleton/extension/phpstan.neon +0 -1
  9. package/boilerplate/skeleton/extension/tests/phpunit.integration.xml +1 -0
  10. package/boilerplate/skeleton/extension/tests/phpunit.unit.xml +2 -1
  11. package/boilerplate/stubs/backend/advanced-api-resource.php +3 -3
  12. package/boilerplate/stubs/backend/model-factory.php +17 -0
  13. package/boilerplate/stubs/backend/model.php +3 -0
  14. package/lib/base-command.d.ts +21 -3
  15. package/lib/base-command.js +52 -5
  16. package/lib/commands/make/backend/model.js +10 -0
  17. package/lib/commands/monorepo/split.js +1 -1
  18. package/lib/commands/upgrade/2.0.d.ts +43 -0
  19. package/lib/commands/upgrade/2.0.js +128 -2
  20. package/lib/steps/gen-ext-scaffolder.d.ts +5 -0
  21. package/lib/steps/gen-ext-scaffolder.js +17 -2
  22. package/lib/steps/monorepo/split.d.ts +2 -1
  23. package/lib/steps/monorepo/split.js +20 -5
  24. package/lib/steps/stubs/backend/advanced-api-resource.js +1 -1
  25. package/lib/steps/stubs/backend/model-factory.d.ts +24 -0
  26. package/lib/steps/stubs/backend/model-factory.js +33 -0
  27. package/lib/steps/upgrade/twopointoh/backend/email-views.js +22 -30
  28. package/lib/steps/upgrade/twopointoh/backend/extension-models.d.ts +30 -0
  29. package/lib/steps/upgrade/twopointoh/backend/extension-models.js +34 -0
  30. package/lib/steps/upgrade/twopointoh/backend/json-api.d.ts +6 -0
  31. package/lib/steps/upgrade/twopointoh/backend/json-api.js +14 -0
  32. package/lib/steps/upgrade/twopointoh/backend/misc.d.ts +6 -0
  33. package/lib/steps/upgrade/twopointoh/backend/misc.js +19 -7
  34. package/lib/steps/upgrade/twopointoh/backend/model-factories.d.ts +18 -0
  35. package/lib/steps/upgrade/twopointoh/backend/model-factories.js +73 -0
  36. package/lib/steps/upgrade/twopointoh/backend/phpunit-xml.d.ts +8 -0
  37. package/lib/steps/upgrade/twopointoh/backend/phpunit-xml.js +33 -0
  38. package/lib/steps/upgrade/twopointoh/backend/phpunit.d.ts +2 -0
  39. package/lib/steps/upgrade/twopointoh/backend/phpunit.js +34 -21
  40. package/lib/steps/upgrade/twopointoh/backend/search.d.ts +9 -0
  41. package/lib/steps/upgrade/twopointoh/backend/search.js +28 -10
  42. package/lib/steps/upgrade/twopointoh/base.d.ts +38 -0
  43. package/lib/steps/upgrade/twopointoh/base.js +122 -24
  44. package/lib/steps/upgrade/twopointoh/dependencies.d.ts +58 -1
  45. package/lib/steps/upgrade/twopointoh/dependencies.js +343 -15
  46. package/lib/steps/upgrade/twopointoh/frontend/admin-extender.d.ts +86 -0
  47. package/lib/steps/upgrade/twopointoh/frontend/admin-extender.js +842 -0
  48. package/lib/steps/upgrade/twopointoh/frontend/code-splitting/usage-of-lazy-modules.js +110 -18
  49. package/lib/steps/upgrade/twopointoh/frontend/export-registry/import-ext.js +13 -16
  50. package/lib/steps/upgrade/twopointoh/frontend/misc.d.ts +23 -0
  51. package/lib/steps/upgrade/twopointoh/frontend/misc.js +147 -8
  52. package/lib/steps/upgrade/twopointoh/infra.d.ts +16 -1
  53. package/lib/steps/upgrade/twopointoh/infra.js +87 -5
  54. package/lib/steps/upgrade/twopointoh/step-failures.d.ts +32 -0
  55. package/lib/steps/upgrade/twopointoh/step-failures.js +54 -0
  56. package/lib/steps/upgrade/twopointoh/todos.d.ts +22 -0
  57. package/lib/steps/upgrade/twopointoh/todos.js +57 -0
  58. package/lib/steps/upgrade/twopointoh/typescript-4-9.d.ts +12 -0
  59. package/lib/steps/upgrade/twopointoh/typescript-4-9.js +200 -0
  60. package/lib/utils/ast.js +5 -10
  61. package/lib/utils/babel-ts.d.ts +9 -0
  62. package/lib/utils/babel-ts.js +18 -0
  63. package/lib/utils/composer.d.ts +5 -0
  64. package/lib/utils/composer.js +10 -1
  65. package/lib/utils/terminal.d.ts +13 -0
  66. package/lib/utils/terminal.js +26 -0
  67. package/oclif.manifest.json +1 -1
  68. package/package.json +1 -1
  69. package/php-subsystem/index.php +12 -0
  70. package/php-subsystem/src/NodeUtil.php +19 -9
  71. package/php-subsystem/src/NodeVisitors/AddExtender.php +49 -1
  72. package/php-subsystem/src/Upgrade/TwoPointOh/JsonApi.php +2 -20
  73. package/php-subsystem/src/Upgrade/TwoPointOh/Misc.php +13 -17
  74. package/php-subsystem/src/Upgrade/TwoPointOh/ModelFactories.php +943 -0
  75. package/php-subsystem/src/Upgrade/TwoPointOh/PhpUnit.php +476 -71
  76. package/php-subsystem/src/Upgrade/TwoPointOh/Search.php +97 -13
@@ -0,0 +1,943 @@
1
+ <?php
2
+
3
+ namespace Flarum\CliPhpSubsystem\Upgrade\TwoPointOh;
4
+
5
+ use PhpParser\Node;
6
+ use PhpParser\NodeTraverser;
7
+ use PhpParser\NodeVisitor;
8
+ use PhpParser\NodeVisitorAbstract;
9
+ use PhpParser\ParserFactory;
10
+
11
+ /**
12
+ * Factories for an extension's models, written from what its migrations say
13
+ * about each model's table.
14
+ */
15
+ class ModelFactories
16
+ {
17
+ /**
18
+ * Blueprint methods that add a column, by the kind of value it holds.
19
+ */
20
+ protected const COLUMN_TYPES = [
21
+ 'increments' => 'int', 'bigIncrements' => 'int', 'mediumIncrements' => 'int', 'smallIncrements' => 'int', 'tinyIncrements' => 'int', 'id' => 'int',
22
+ 'integer' => 'int', 'bigInteger' => 'int', 'mediumInteger' => 'int', 'smallInteger' => 'int', 'tinyInteger' => 'int',
23
+ 'unsignedInteger' => 'int', 'unsignedBigInteger' => 'int', 'unsignedMediumInteger' => 'int', 'unsignedSmallInteger' => 'int', 'unsignedTinyInteger' => 'int',
24
+ 'foreignId' => 'int', 'year' => 'int',
25
+ 'string' => 'string', 'char' => 'string', 'macAddress' => 'string', 'ulid' => 'string',
26
+ 'text' => 'text', 'tinyText' => 'text', 'mediumText' => 'text', 'longText' => 'text',
27
+ 'boolean' => 'bool',
28
+ 'dateTime' => 'datetime', 'dateTimeTz' => 'datetime', 'timestamp' => 'datetime', 'timestampTz' => 'datetime',
29
+ 'date' => 'datetime', 'time' => 'datetime', 'timeTz' => 'datetime',
30
+ 'json' => 'json', 'jsonb' => 'json',
31
+ 'decimal' => 'number', 'unsignedDecimal' => 'number', 'float' => 'number', 'double' => 'number',
32
+ 'enum' => 'enum', 'set' => 'enum',
33
+ 'uuid' => 'uuid',
34
+ 'ipAddress' => 'ip',
35
+ 'binary' => 'binary',
36
+ ];
37
+
38
+ protected const AUTO_INCREMENTS = ['increments', 'bigIncrements', 'mediumIncrements', 'smallIncrements', 'tinyIncrements', 'id'];
39
+
40
+ /**
41
+ * Each table's columns, by name, in the order they were added.
42
+ *
43
+ * @var array<string, array<string, array>>
44
+ */
45
+ protected array $tables = [];
46
+
47
+ /**
48
+ * Replay the migrations' `up`s, in the order Flarum runs them, and return
49
+ * the tables they leave.
50
+ *
51
+ * @param array<string, string> $migrations Each migration's code, by file name.
52
+ */
53
+ public function schema(array $migrations): array
54
+ {
55
+ ksort($migrations);
56
+
57
+ $this->tables = [];
58
+ $parser = (new ParserFactory())->createForHostVersion();
59
+
60
+ foreach ($migrations as $code) {
61
+ foreach ($parser->parse($code) ?? [] as $statement) {
62
+ if ($statement instanceof Node\Stmt\Return_ && $statement->expr) {
63
+ $this->migration($statement->expr);
64
+ }
65
+ }
66
+ }
67
+
68
+ return ['tables' => (object) array_map('array_values', $this->tables)];
69
+ }
70
+
71
+ protected function migration(Node\Expr $migration): void
72
+ {
73
+ if ($migration instanceof Node\Expr\StaticCall && $migration->class instanceof Node\Name && $migration->class->getLast() === 'Migration' && $migration->name instanceof Node\Identifier) {
74
+ $this->helper($migration->name->toString(), array_map(fn (Node\Arg $arg) => $arg->value, $migration->getArgs()));
75
+
76
+ return;
77
+ }
78
+
79
+ if ($migration instanceof Node\Expr\Array_) {
80
+ foreach ($migration->items as $item) {
81
+ if ($item && $this->literal($item->key) === 'up' && ($item->value instanceof Node\Expr\Closure || $item->value instanceof Node\Expr\ArrowFunction)) {
82
+ $this->up($item->value);
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * What a `Flarum\Database\Migration` helper does.
90
+ */
91
+ protected function helper(string $name, array $args): void
92
+ {
93
+ $table = $this->literal($args[0] ?? null);
94
+
95
+ if (! is_string($table)) {
96
+ return;
97
+ }
98
+
99
+ switch ($name) {
100
+ case 'createTable':
101
+ case 'createTableIfNotExists':
102
+ $this->tables[$table] = [];
103
+
104
+ if (isset($args[1])) {
105
+ $this->blueprint($table, $args[1]);
106
+ }
107
+ break;
108
+
109
+ case 'renameTable':
110
+ $this->renameTable($table, $this->literal($args[1] ?? null));
111
+ break;
112
+
113
+ case 'addColumns':
114
+ case 'dropColumns':
115
+ foreach ($args[1]->items ?? [] as $item) {
116
+ $column = $this->literal($item?->key);
117
+
118
+ if (! is_string($column)) {
119
+ continue;
120
+ }
121
+
122
+ if ($name === 'dropColumns') {
123
+ unset($this->tables[$table][$column]);
124
+ } elseif ($item->value instanceof Node\Expr\Array_) {
125
+ $this->addColumn($table, $column, $item->value);
126
+ }
127
+ }
128
+ break;
129
+
130
+ case 'renameColumn':
131
+ $this->renameColumn($table, $this->literal($args[1] ?? null), $this->literal($args[2] ?? null));
132
+ break;
133
+
134
+ case 'renameColumns':
135
+ foreach ($args[1]->items ?? [] as $item) {
136
+ $this->renameColumn($table, $this->literal($item?->key), $this->literal($item?->value));
137
+ }
138
+ break;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * What the schema builder does in an `up`, in order.
144
+ */
145
+ protected function up(Node\Expr\Closure|Node\Expr\ArrowFunction $up): void
146
+ {
147
+ $traverser = new NodeTraverser();
148
+
149
+ $traverser->addVisitor(new class ($this) extends NodeVisitorAbstract {
150
+ public function __construct(private ModelFactories $factories)
151
+ {
152
+ }
153
+
154
+ public function enterNode(Node $node)
155
+ {
156
+ if (! $node instanceof Node\Expr\MethodCall || ! $node->name instanceof Node\Identifier) {
157
+ return null;
158
+ }
159
+
160
+ $args = array_map(fn (Node\Arg $arg) => $arg->value, $node->getArgs());
161
+
162
+ return $this->factories->builder($node->name->toString(), $args) ? NodeVisitor::DONT_TRAVERSE_CHILDREN : null;
163
+ }
164
+ });
165
+
166
+ $traverser->traverse($up instanceof Node\Expr\Closure ? $up->stmts : [new Node\Stmt\Expression($up->expr)]);
167
+ }
168
+
169
+ /**
170
+ * A schema builder call, if it is one that changes a table.
171
+ */
172
+ public function builder(string $method, array $args): bool
173
+ {
174
+ $table = $this->literal($args[0] ?? null);
175
+ $callback = $args[1] ?? null;
176
+
177
+ if (! is_string($table)) {
178
+ return false;
179
+ }
180
+
181
+ if (in_array($method, ['create', 'table']) && ($callback instanceof Node\Expr\Closure || $callback instanceof Node\Expr\ArrowFunction)) {
182
+ if ($method === 'create') {
183
+ $this->tables[$table] = [];
184
+ }
185
+
186
+ $this->blueprint($table, $callback);
187
+
188
+ return true;
189
+ }
190
+
191
+ if ($method === 'rename' && count($args) === 2) {
192
+ $this->renameTable($table, $this->literal($args[1]));
193
+
194
+ return true;
195
+ }
196
+
197
+ if (in_array($method, ['drop', 'dropIfExists']) && count($args) === 1) {
198
+ unset($this->tables[$table]);
199
+
200
+ return true;
201
+ }
202
+
203
+ return false;
204
+ }
205
+
206
+ /**
207
+ * What a Blueprint callback does to its table.
208
+ */
209
+ protected function blueprint(string $table, Node\Expr $callback): void
210
+ {
211
+ $this->tables[$table] ??= [];
212
+
213
+ $param = $callback->params[0]->var->name ?? null;
214
+
215
+ if (! is_string($param) || ! ($callback instanceof Node\Expr\Closure || $callback instanceof Node\Expr\ArrowFunction)) {
216
+ return;
217
+ }
218
+
219
+ $statements = $callback instanceof Node\Expr\Closure ? $callback->stmts : [new Node\Stmt\Expression($callback->expr)];
220
+
221
+ $traverser = new NodeTraverser();
222
+
223
+ $traverser->addVisitor(new class ($this, $table, $param) extends NodeVisitorAbstract {
224
+ public function __construct(private ModelFactories $factories, private string $table, private string $param)
225
+ {
226
+ }
227
+
228
+ public function enterNode(Node $node)
229
+ {
230
+ if (! $node instanceof Node\Stmt\Expression) {
231
+ return null;
232
+ }
233
+
234
+ // $table->string('name')->nullable(): the call on $table, then its modifiers.
235
+ $calls = [];
236
+ $expr = $node->expr;
237
+
238
+ while ($expr instanceof Node\Expr\MethodCall && $expr->name instanceof Node\Identifier) {
239
+ array_unshift($calls, $expr);
240
+ $expr = $expr->var;
241
+ }
242
+
243
+ if ($calls && $expr instanceof Node\Expr\Variable && $expr->name === $this->param) {
244
+ $this->factories->column($this->table, $calls);
245
+ }
246
+
247
+ return NodeVisitor::DONT_TRAVERSE_CHILDREN;
248
+ }
249
+ });
250
+
251
+ $traverser->traverse($statements);
252
+ }
253
+
254
+ /**
255
+ * What one chain of Blueprint calls does to the table.
256
+ *
257
+ * @param Node\Expr\MethodCall[] $calls
258
+ */
259
+ public function column(string $table, array $calls): void
260
+ {
261
+ $method = $calls[0]->name->toString();
262
+ $args = array_map(fn (Node\Arg $arg) => $arg->value, $calls[0]->getArgs());
263
+ $modifiers = array_slice($calls, 1);
264
+
265
+ switch ($method) {
266
+ case 'timestamps':
267
+ case 'nullableTimestamps':
268
+ case 'timestampsTz':
269
+ $this->define($table, ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true]);
270
+ $this->define($table, ['name' => 'updated_at', 'type' => 'datetime', 'nullable' => true]);
271
+
272
+ return;
273
+
274
+ case 'softDeletes':
275
+ case 'softDeletesTz':
276
+ $this->define($table, ['name' => $this->literal($args[0] ?? null) ?? 'deleted_at', 'type' => 'datetime', 'nullable' => true]);
277
+
278
+ return;
279
+
280
+ case 'rememberToken':
281
+ $this->define($table, ['name' => 'remember_token', 'type' => 'string', 'nullable' => true]);
282
+
283
+ return;
284
+
285
+ case 'morphs':
286
+ case 'nullableMorphs':
287
+ $name = $this->literal($args[0] ?? null);
288
+
289
+ if (is_string($name)) {
290
+ $this->define($table, ['name' => "{$name}_type", 'type' => 'string', 'nullable' => $method === 'nullableMorphs']);
291
+ $this->define($table, ['name' => "{$name}_id", 'type' => 'int', 'nullable' => $method === 'nullableMorphs']);
292
+ }
293
+
294
+ return;
295
+
296
+ case 'addColumn':
297
+ $type = $this->literal($args[0] ?? null);
298
+ $name = $this->literal($args[1] ?? null);
299
+
300
+ if (is_string($type) && is_string($name)) {
301
+ $this->addColumn($table, $name, new Node\Expr\Array_([
302
+ new Node\ArrayItem(new Node\Scalar\String_($type)),
303
+ ...(($args[2] ?? null) instanceof Node\Expr\Array_ ? $args[2]->items : []),
304
+ ]));
305
+ }
306
+
307
+ return;
308
+
309
+ case 'foreign':
310
+ $on = $this->modifierArgument($modifiers, 'on');
311
+
312
+ foreach ($this->names($args[0] ?? null) as $name) {
313
+ if (is_string($on) && isset($this->tables[$table][$name])) {
314
+ $this->tables[$table][$name]['references'] = $on;
315
+ }
316
+ }
317
+
318
+ return;
319
+
320
+ case 'dropForeign':
321
+ foreach ($this->names($args[0] ?? null) as $name) {
322
+ // An index name, `{table}_{column}_foreign`, or the column.
323
+ $name = preg_replace('/^'.preg_quote($table, '/').'_(.+)_foreign$/', '$1', $name);
324
+
325
+ if (isset($this->tables[$table][$name])) {
326
+ $this->tables[$table][$name]['references'] = null;
327
+ }
328
+ }
329
+
330
+ return;
331
+
332
+ case 'dropColumn':
333
+ foreach ($args as $arg) {
334
+ foreach ($this->names($arg) as $name) {
335
+ unset($this->tables[$table][$name]);
336
+ }
337
+ }
338
+
339
+ return;
340
+
341
+ case 'renameColumn':
342
+ $this->renameColumn($table, $this->literal($args[0] ?? null), $this->literal($args[1] ?? null));
343
+
344
+ return;
345
+ }
346
+
347
+ if (! isset(self::COLUMN_TYPES[$method])) {
348
+ return;
349
+ }
350
+
351
+ $column = [
352
+ 'name' => $method === 'id' ? ($this->literal($args[0] ?? null) ?? 'id') : $this->literal($args[0] ?? null),
353
+ 'type' => self::COLUMN_TYPES[$method],
354
+ 'nullable' => false,
355
+ 'autoIncrement' => in_array($method, self::AUTO_INCREMENTS)
356
+ // integer('id', true)
357
+ || (self::COLUMN_TYPES[$method] === 'int' && $this->literal($args[1] ?? null) === true),
358
+ ];
359
+
360
+ if ($column['type'] === 'enum') {
361
+ $column['values'] = array_values(array_filter(array_map(fn ($item) => $this->literal($item?->value), $args[1]->items ?? []), 'is_string'));
362
+ }
363
+
364
+ foreach ($modifiers as $modifier) {
365
+ $column = $this->modify($column, $modifier);
366
+ }
367
+
368
+ if (is_string($column['name'])) {
369
+ $this->define($table, $column);
370
+ }
371
+ }
372
+
373
+ /**
374
+ * A column with one modifier applied.
375
+ */
376
+ protected function modify(array $column, Node\Expr\MethodCall $modifier): array
377
+ {
378
+ $args = $modifier->getArgs();
379
+
380
+ switch ($modifier->name->toString()) {
381
+ case 'nullable':
382
+ $column['nullable'] = ! isset($args[0]) || $this->literal($args[0]->value) !== false;
383
+ break;
384
+
385
+ case 'default':
386
+ if (isset($args[0]) && $this->isLiteral($args[0]->value)) {
387
+ $column['default'] = ['value' => $this->literal($args[0]->value)];
388
+ } else {
389
+ $column['defaultExpression'] = true;
390
+ }
391
+ break;
392
+
393
+ case 'useCurrent':
394
+ $column['defaultExpression'] = true;
395
+ break;
396
+
397
+ case 'autoIncrement':
398
+ $column['autoIncrement'] = true;
399
+ break;
400
+
401
+ case 'constrained':
402
+ $on = isset($args[0]) ? $this->literal($args[0]->value) : null;
403
+ $column['references'] = is_string($on) ? $on : preg_replace('/_id$/', '', (string) $column['name']).'s';
404
+ break;
405
+
406
+ case 'on':
407
+ $on = isset($args[0]) ? $this->literal($args[0]->value) : null;
408
+ $column['references'] = is_string($on) ? $on : null;
409
+ break;
410
+
411
+ case 'change':
412
+ $column['change'] = true;
413
+ break;
414
+ }
415
+
416
+ return $column;
417
+ }
418
+
419
+ /**
420
+ * A column as `Migration::addColumns()` and `$table->addColumn()` give it:
421
+ * its type, then its options.
422
+ */
423
+ protected function addColumn(string $table, string $name, Node\Expr\Array_ $definition): void
424
+ {
425
+ $column = ['name' => $name, 'type' => null, 'nullable' => false, 'autoIncrement' => false];
426
+
427
+ foreach ($definition->items as $item) {
428
+ $value = $this->literal($item?->value);
429
+ $option = $this->literal($item?->key);
430
+
431
+ if ($item && $item->key === null && $column['type'] === null && is_string($value)) {
432
+ $column['type'] = self::COLUMN_TYPES[$value] ?? null;
433
+ } elseif ($option === 'nullable') {
434
+ $column['nullable'] = (bool) $value;
435
+ } elseif ($option === 'default') {
436
+ if ($this->isLiteral($item->value)) {
437
+ $column['default'] = ['value' => $value];
438
+ } else {
439
+ $column['defaultExpression'] = true;
440
+ }
441
+ } elseif ($option === 'autoIncrement') {
442
+ $column['autoIncrement'] = (bool) $value;
443
+ } elseif ($option === 'allowed' && $item->value instanceof Node\Expr\Array_) {
444
+ $column['values'] = array_values(array_filter(array_map(fn ($allowed) => $this->literal($allowed?->value), $item->value->items), 'is_string'));
445
+ }
446
+ }
447
+
448
+ if ($column['type'] !== null) {
449
+ $this->define($table, $column);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Add the column, or, given change(), replace the one of that name. Since
455
+ * Laravel 11, a changed column keeps nothing it does not restate. Its
456
+ * foreign key is a separate constraint, so that stays.
457
+ */
458
+ protected function define(string $table, array $column): void
459
+ {
460
+ $existing = $this->tables[$table][$column['name']] ?? null;
461
+
462
+ unset($column['change']);
463
+
464
+ $column += ['nullable' => false, 'autoIncrement' => false, 'references' => $existing['references'] ?? null];
465
+
466
+ if ($column['references'] === null && $existing) {
467
+ $column['references'] = $existing['references'] ?? null;
468
+ }
469
+
470
+ $this->tables[$table][$column['name']] = $column;
471
+ }
472
+
473
+ protected function renameTable(string $from, mixed $to): void
474
+ {
475
+ if (is_string($to) && isset($this->tables[$from])) {
476
+ $this->tables[$to] = $this->tables[$from];
477
+ unset($this->tables[$from]);
478
+ }
479
+ }
480
+
481
+ protected function renameColumn(string $table, mixed $from, mixed $to): void
482
+ {
483
+ if (! is_string($from) || ! is_string($to) || ! isset($this->tables[$table][$from])) {
484
+ return;
485
+ }
486
+
487
+ $renamed = [];
488
+
489
+ foreach ($this->tables[$table] as $name => $column) {
490
+ $renamed[$name === $from ? $to : $name] = $name === $from ? ['name' => $to] + $column : $column;
491
+ }
492
+
493
+ $this->tables[$table] = $renamed;
494
+ }
495
+
496
+ /**
497
+ * The column names a call was given: one, or a list.
498
+ *
499
+ * @return string[]
500
+ */
501
+ protected function names(?Node\Expr $arg): array
502
+ {
503
+ if ($arg instanceof Node\Expr\Array_) {
504
+ return array_values(array_filter(array_map(fn ($item) => $this->literal($item?->value), $arg->items), 'is_string'));
505
+ }
506
+
507
+ $name = $this->literal($arg);
508
+
509
+ return is_string($name) ? [$name] : [];
510
+ }
511
+
512
+ /**
513
+ * @param Node\Expr\MethodCall[] $modifiers
514
+ */
515
+ protected function modifierArgument(array $modifiers, string $method): mixed
516
+ {
517
+ foreach ($modifiers as $modifier) {
518
+ if ($modifier->name->toString() === $method && isset($modifier->getArgs()[0])) {
519
+ return $this->literal($modifier->getArgs()[0]->value);
520
+ }
521
+ }
522
+
523
+ return null;
524
+ }
525
+
526
+ /**
527
+ * Core models with a factory, by table.
528
+ */
529
+ public const CORE_MODELS = [
530
+ 'users' => 'Flarum\User\User',
531
+ 'discussions' => 'Flarum\Discussion\Discussion',
532
+ 'posts' => 'Flarum\Post\Post',
533
+ 'groups' => 'Flarum\Group\Group',
534
+ 'access_tokens' => 'Flarum\Http\AccessToken',
535
+ 'api_keys' => 'Flarum\Api\ApiKey',
536
+ 'tags' => 'Flarum\Tags\Tag',
537
+ 'flags' => 'Flarum\Flags\Flag',
538
+ ];
539
+
540
+ protected const HAS_FACTORY = 'Illuminate\Database\Eloquent\Factories\HasFactory';
541
+
542
+ protected const MODEL_BASES = ['Flarum\Database\AbstractModel', 'Illuminate\Database\Eloquent\Model'];
543
+
544
+ /**
545
+ * The models among an extension's classes: those that extend a model base
546
+ * class directly or through the extension's own classes. A subclass of a
547
+ * core model, such as a post type, shares that model's table and factory.
548
+ *
549
+ * @param array<string, string> $files Each file's code, by path.
550
+ */
551
+ public function models(array $files): array
552
+ {
553
+ $classes = [];
554
+
555
+ foreach ($files as $file => $code) {
556
+ foreach ($this->classesIn($code) as $class) {
557
+ $classes[$class['class']] = $class + ['file' => $file];
558
+ }
559
+ }
560
+
561
+ $models = [];
562
+
563
+ foreach ($classes as $name => $class) {
564
+ $ancestors = [];
565
+ $parent = $class['parent'];
566
+
567
+ while ($parent !== null && isset($classes[$parent]) && ! in_array($parent, $ancestors)) {
568
+ $ancestors[] = $parent;
569
+ $parent = $classes[$parent]['parent'];
570
+ }
571
+
572
+ if ($class['abstract'] || ! in_array($parent, self::MODEL_BASES)) {
573
+ continue;
574
+ }
575
+
576
+ $lineage = [$class, ...array_map(fn (string $ancestor) => $classes[$ancestor], $ancestors)];
577
+
578
+ $models[] = [
579
+ 'file' => $class['file'],
580
+ 'class' => $name,
581
+ 'table' => array_values(array_filter(array_column($lineage, 'table')))[0] ?? null,
582
+ 'hasFactory' => in_array(true, array_column($lineage, 'hasFactory'), true),
583
+ ];
584
+ }
585
+
586
+ usort($models, fn (array $a, array $b) => strcmp($a['file'], $b['file']));
587
+
588
+ return ['models' => $models, 'core' => self::CORE_MODELS];
589
+ }
590
+
591
+ /**
592
+ * The classes a file declares, with names resolved.
593
+ */
594
+ protected function classesIn(string $code): array
595
+ {
596
+ $ast = (new ParserFactory())->createForHostVersion()->parse($code) ?? [];
597
+
598
+ $traverser = new NodeTraverser();
599
+ $traverser->addVisitor(new NodeVisitor\NameResolver());
600
+ $ast = $traverser->traverse($ast);
601
+
602
+ $classes = [];
603
+
604
+ foreach ((new \PhpParser\NodeFinder())->findInstanceOf($ast, Node\Stmt\Class_::class) as $class) {
605
+ if (! $class->namespacedName) {
606
+ continue;
607
+ }
608
+
609
+ $table = null;
610
+ $hasFactory = false;
611
+
612
+ foreach ($class->stmts as $statement) {
613
+ if ($statement instanceof Node\Stmt\Property) {
614
+ foreach ($statement->props as $property) {
615
+ if ($property->name->toString() === 'table' && is_string($this->literal($property->default))) {
616
+ $table = $this->literal($property->default);
617
+ }
618
+ }
619
+ }
620
+
621
+ if ($statement instanceof Node\Stmt\TraitUse) {
622
+ foreach ($statement->traits as $trait) {
623
+ $hasFactory = $hasFactory || $trait->toString() === self::HAS_FACTORY;
624
+ }
625
+ }
626
+ }
627
+
628
+ $classes[] = [
629
+ 'class' => $class->namespacedName->toString(),
630
+ 'parent' => $class->extends?->toString(),
631
+ 'abstract' => $class->isAbstract(),
632
+ 'table' => $table,
633
+ 'hasFactory' => $hasFactory,
634
+ ];
635
+ }
636
+
637
+ return $classes;
638
+ }
639
+
640
+ /**
641
+ * A factory for the model the file declares, from its table's columns,
642
+ * and the model with the HasFactory trait.
643
+ *
644
+ * @param array{columns: ?array, models: array<string, string>} $data
645
+ * The model's table's columns (null if the migrations say nothing of
646
+ * it), and the models with a factory, by table.
647
+ */
648
+ public function factory(string $file, string $code, array $data): array
649
+ {
650
+ $ast = (new ParserFactory())->createForHostVersion()->parse($code) ?? [];
651
+
652
+ $traverser = new NodeTraverser();
653
+ $traverser->addVisitor(new NodeVisitor\NameResolver(null, ['replaceNodes' => false]));
654
+ $ast = $traverser->traverse($ast);
655
+
656
+ $finder = new \PhpParser\NodeFinder();
657
+ $namespace = $finder->findFirstInstanceOf($ast, Node\Stmt\Namespace_::class);
658
+ $class = $finder->findFirst($ast, fn (Node $node) => $node instanceof Node\Stmt\Class_ && ! $node->isAbstract() && $node->name);
659
+
660
+ if (! $namespace || ! $class) {
661
+ return ['code' => $code, 'factory' => null];
662
+ }
663
+
664
+ $model = $class->namespacedName->toString();
665
+ $table = array_search($model, $data['models'] ?? [], true);
666
+
667
+ return [
668
+ 'code' => $this->withHasFactory($code, $namespace, $class),
669
+ 'factory' => $this->factoryCode($namespace, $class->name->toString(), $data['columns'] ?? null, $data['models'] ?? [], is_string($table) ? $table : null),
670
+ ];
671
+ }
672
+
673
+ protected function factoryCode(Node\Stmt\Namespace_ $namespace, string $model, ?array $columns, array $models, ?string $table): string
674
+ {
675
+ $uses = ['Illuminate\Database\Eloquent\Factories\Factory' => 'Factory'];
676
+ $lines = [];
677
+
678
+ foreach ($columns ?? [] as $column) {
679
+ $value = $this->default($column, $table, $models, $namespace->name->toString(), $uses);
680
+
681
+ if ($value === 'SKIP') {
682
+ continue;
683
+ }
684
+
685
+ $lines[] = str_starts_with($value, '//')
686
+ ? ' '.$value
687
+ : ' '.$this->export($column['name']).' => '.$value.',';
688
+ }
689
+
690
+ if ($columns === null) {
691
+ $lines = [' // @TODO: give each column a default, so tests only need to set the ones they care about.'];
692
+ }
693
+
694
+ ksort($uses, SORT_STRING | SORT_FLAG_CASE);
695
+
696
+ $header = '';
697
+
698
+ foreach ($namespace->getComments() as $comment) {
699
+ $header .= $comment->getText()."\n\n";
700
+ }
701
+
702
+ return "<?php\n\n".$header.'namespace '.$namespace->name->toString().";\n\n"
703
+ .implode('', array_map(fn (string $use) => "use $use;\n", array_keys($uses)))
704
+ ."\nclass {$model}Factory extends Factory\n{\n"
705
+ ." public function definition(): array\n {\n return [\n"
706
+ .implode("\n", $lines).($lines ? "\n" : '')
707
+ ." ];\n }\n}\n";
708
+ }
709
+
710
+ /**
711
+ * The PHP for a column's default in the factory, a // @TODO line when it
712
+ * needs one the upgrade can't give, or SKIP when the database gives it.
713
+ */
714
+ protected function default(array $column, ?string $table, array $models, string $namespace, array &$uses): string
715
+ {
716
+ $name = $column['name'];
717
+
718
+ if ($column['autoIncrement']) {
719
+ return 'SKIP';
720
+ }
721
+
722
+ if ($name === 'created_at') {
723
+ $uses['Carbon\Carbon'] = 'Carbon';
724
+
725
+ return 'Carbon::now()';
726
+ }
727
+
728
+ if ($column['references'] ?? null) {
729
+ if ($column['nullable']) {
730
+ return 'null';
731
+ }
732
+
733
+ $related = $models[$column['references']] ?? null;
734
+
735
+ // A model pointing at its own table would make one of itself forever.
736
+ if (! $related || $column['references'] === $table) {
737
+ return "// @TODO: give $name a default, such as a factory for the model it points at, or set it in each test.";
738
+ }
739
+
740
+ return $this->classReference($related, $namespace, $uses).'::factory()';
741
+ }
742
+
743
+ if (array_key_exists('default', $column)) {
744
+ return $this->export($column['default']['value']);
745
+ }
746
+
747
+ if ($column['defaultExpression'] ?? false) {
748
+ return 'SKIP';
749
+ }
750
+
751
+ if ($column['nullable']) {
752
+ return 'null';
753
+ }
754
+
755
+ switch ($column['type']) {
756
+ case 'int':
757
+ case 'number':
758
+ return '0';
759
+ case 'bool':
760
+ return 'false';
761
+ case 'datetime':
762
+ $uses['Carbon\Carbon'] = 'Carbon';
763
+
764
+ return 'Carbon::now()';
765
+ case 'json':
766
+ return '[]';
767
+ case 'enum':
768
+ return isset($column['values'][0]) ? $this->export($column['values'][0]) : "// @TODO: give $name a default, or set it in each test.";
769
+ case 'uuid':
770
+ return '$this->faker->uuid';
771
+ case 'ip':
772
+ return '$this->faker->ipv4';
773
+ case 'binary':
774
+ return "''";
775
+ case 'string':
776
+ case 'text':
777
+ return match (true) {
778
+ $name === 'slug' => '$this->faker->slug',
779
+ $name === 'email' => '$this->faker->safeEmail',
780
+ $name === 'ip_address' => '$this->faker->ipv4',
781
+ $name === 'url' || str_ends_with($name, '_url') => '$this->faker->url',
782
+ $name === 'color' => '$this->faker->hexColor',
783
+ $column['type'] === 'text' => '$this->faker->sentence',
784
+ default => '$this->faker->word',
785
+ };
786
+ }
787
+
788
+ return "// @TODO: give $name a default, or set it in each test.";
789
+ }
790
+
791
+ /**
792
+ * How the factory names a model class: imported, unless it is in the
793
+ * factory's namespace or its name is taken.
794
+ */
795
+ protected function classReference(string $class, string $namespace, array &$uses): string
796
+ {
797
+ $short = substr($class, strrpos($class, '\\') + 1);
798
+
799
+ if (substr($class, 0, -strlen($short) - 1) === $namespace) {
800
+ return $short;
801
+ }
802
+
803
+ if (isset($uses[$class]) || ! in_array($short, $uses, true)) {
804
+ $uses[$class] = $short;
805
+
806
+ return $short;
807
+ }
808
+
809
+ return '\\'.$class;
810
+ }
811
+
812
+ protected function export(mixed $value): string
813
+ {
814
+ return match (true) {
815
+ $value === null => 'null',
816
+ is_bool($value) => $value ? 'true' : 'false',
817
+ is_int($value), is_float($value) => (string) $value,
818
+ default => "'".addcslashes((string) $value, "\\'")."'",
819
+ };
820
+ }
821
+
822
+ /**
823
+ * The model's code with the HasFactory trait, and its import, added where
824
+ * they belong. Edited as text, from the end, so the rest stays as it is.
825
+ */
826
+ protected function withHasFactory(string $code, Node\Stmt\Namespace_ $namespace, Node\Stmt\Class_ $class): string
827
+ {
828
+ foreach ($class->stmts as $statement) {
829
+ if ($statement instanceof Node\Stmt\TraitUse) {
830
+ foreach ($statement->traits as $trait) {
831
+ if ($trait->getAttribute('resolvedName')?->toString() === self::HAS_FACTORY) {
832
+ return $code;
833
+ }
834
+ }
835
+ }
836
+ }
837
+
838
+ $edits = [];
839
+
840
+ // The trait: with the class's other traits, else above everything else.
841
+ $first = $class->stmts[0] ?? null;
842
+ $trait = null;
843
+
844
+ foreach ($class->stmts as $statement) {
845
+ if ($statement instanceof Node\Stmt\TraitUse) {
846
+ $trait = $statement;
847
+ break;
848
+ }
849
+ }
850
+
851
+ if ($trait || $first) {
852
+ $at = $this->startOf($trait ?? $first);
853
+ $indent = $this->indentAt($code, $at);
854
+ $edits[$at] = 'use HasFactory;'."\n".($trait ? '' : "\n").$indent;
855
+ } else {
856
+ // An empty body: everything between its braces becomes the trait.
857
+ $close = $class->getEndFilePos();
858
+ $open = strrpos(substr($code, 0, $close), '{');
859
+ $indent = $this->indentAt($code, $class->getStartFilePos());
860
+ $edits[$open] = "{\n$indent use HasFactory;\n$indent}";
861
+ $code = substr($code, 0, $open).substr($code, $close + 1);
862
+ }
863
+
864
+ // The import: in order among the others, unless it is there already.
865
+ $imports = array_values(array_filter($namespace->stmts, fn (Node $node) => $node instanceof Node\Stmt\Use_ && $node->type === Node\Stmt\Use_::TYPE_NORMAL));
866
+ $imported = array_map(fn (Node\Stmt\Use_ $use) => $use->uses[0]->name->toString(), $imports);
867
+
868
+ if (! in_array(self::HAS_FACTORY, $imported)) {
869
+ $after = null;
870
+
871
+ foreach ($imports as $import) {
872
+ if (strcasecmp($import->uses[0]->name->toString(), self::HAS_FACTORY) > 0) {
873
+ $edits[$this->startOf($import)] = 'use '.self::HAS_FACTORY.";\n";
874
+ $after = false;
875
+ break;
876
+ }
877
+
878
+ $after = $import;
879
+ }
880
+
881
+ if ($after) {
882
+ $edits[$after->getEndFilePos() + 1] = "\nuse ".self::HAS_FACTORY.';';
883
+ } elseif ($after === null) {
884
+ $edits[$namespace->name->getEndFilePos() + 2] = "\n\nuse ".self::HAS_FACTORY.';';
885
+ }
886
+ }
887
+
888
+ krsort($edits);
889
+
890
+ foreach ($edits as $at => $text) {
891
+ $code = substr($code, 0, $at).$text.substr($code, $at);
892
+ }
893
+
894
+ return $code;
895
+ }
896
+
897
+ /**
898
+ * Where a statement starts, comments included.
899
+ */
900
+ protected function startOf(Node $node): int
901
+ {
902
+ $comments = $node->getComments();
903
+
904
+ return $comments ? $comments[0]->getStartFilePos() : $node->getStartFilePos();
905
+ }
906
+
907
+ protected function indentAt(string $code, int $at): string
908
+ {
909
+ $lineStart = strrpos(substr($code, 0, $at), "\n");
910
+
911
+ return substr($code, $lineStart === false ? 0 : $lineStart + 1, $at - ($lineStart === false ? 0 : $lineStart + 1));
912
+ }
913
+
914
+ protected function isLiteral(?Node\Expr $expr): bool
915
+ {
916
+ return $expr instanceof Node\Scalar\String_
917
+ || $expr instanceof Node\Scalar\Int_
918
+ || $expr instanceof Node\Scalar\Float_
919
+ || ($expr instanceof Node\Expr\UnaryMinus && ($expr->expr instanceof Node\Scalar\Int_ || $expr->expr instanceof Node\Scalar\Float_))
920
+ || ($expr instanceof Node\Expr\ConstFetch && in_array($expr->name->toLowerString(), ['true', 'false', 'null']));
921
+ }
922
+
923
+ /**
924
+ * The value of a literal: a string, number, boolean or null. Anything else
925
+ * is null.
926
+ */
927
+ protected function literal(?Node\Expr $expr): mixed
928
+ {
929
+ if (! $this->isLiteral($expr)) {
930
+ return null;
931
+ }
932
+
933
+ if ($expr instanceof Node\Expr\UnaryMinus) {
934
+ return -$expr->expr->value;
935
+ }
936
+
937
+ if ($expr instanceof Node\Expr\ConstFetch) {
938
+ return ['true' => true, 'false' => false, 'null' => null][$expr->name->toLowerString()];
939
+ }
940
+
941
+ return $expr->value;
942
+ }
943
+ }