@omnifyjp/ts 7.0.2 → 7.1.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/dist/php/faker-mapper.js +58 -20
- package/dist/php/orphan-cleanup.js +71 -21
- package/dist/php/request-generator.js +41 -2
- package/package.json +1 -1
package/dist/php/faker-mapper.js
CHANGED
|
@@ -18,8 +18,8 @@ export function toFaker(propName, property, schemas = {}) {
|
|
|
18
18
|
case 'Int':
|
|
19
19
|
case 'BigInt': return intFaker(snakeName, property);
|
|
20
20
|
case 'TinyInt': return `'${snakeName}' => fake()->numberBetween(0, 127),`;
|
|
21
|
-
case 'Float':
|
|
22
|
-
case 'Decimal': return
|
|
21
|
+
case 'Float': return `'${snakeName}' => fake()->randomFloat(2, 1, 10000),`;
|
|
22
|
+
case 'Decimal': return decimalFaker(snakeName, property);
|
|
23
23
|
case 'Boolean': return `'${snakeName}' => fake()->boolean(),`;
|
|
24
24
|
case 'Text': return `'${snakeName}' => fake()->paragraphs(3, true),`;
|
|
25
25
|
case 'MediumText': return `'${snakeName}' => fake()->paragraphs(3, true),`;
|
|
@@ -82,43 +82,81 @@ export function associationFaker(propName, property, modelNamespace, currentSche
|
|
|
82
82
|
return { fake, import: importLine };
|
|
83
83
|
}
|
|
84
84
|
function stringFaker(propName, snakeName, property) {
|
|
85
|
+
const expr = stringFakerExpr(propName, snakeName, property);
|
|
86
|
+
const length = property['length'];
|
|
87
|
+
// Clamp to the column's actual length regardless of which branch above
|
|
88
|
+
// picked the generator: a wrong/over-eager name heuristic (or a caller
|
|
89
|
+
// constraining `length` below what the heuristic normally produces) must
|
|
90
|
+
// stay inside the column instead of overflowing it — an SQL 1406 on
|
|
91
|
+
// MySQL/MariaDB weeks after the schema shipped (sqlite enforces no such
|
|
92
|
+
// limit, so this was invisible locally). Applied uniformly to every
|
|
93
|
+
// generator here rather than cherry-picked per branch (#264).
|
|
94
|
+
const value = length != null ? `mb_substr(${expr}, 0, ${length})` : expr;
|
|
95
|
+
return `'${snakeName}' => ${value},`;
|
|
96
|
+
}
|
|
97
|
+
function stringFakerExpr(propName, snakeName, property) {
|
|
85
98
|
const lower = propName.toLowerCase();
|
|
99
|
+
// Name-heuristic tokens, split on the already-normalized snake_case column
|
|
100
|
+
// name so a single word boundary decides a match — `shipAddress` must not
|
|
101
|
+
// trip the bare `ip` check meant for `ipAddress` (#264: more-specific
|
|
102
|
+
// patterns must win, deterministically, not by accidental substring order).
|
|
103
|
+
const tokens = snakeName.split('_');
|
|
86
104
|
if (propName === 'slug')
|
|
87
|
-
return `
|
|
105
|
+
return `fake()->unique()->slug(2)`;
|
|
88
106
|
if (propName === 'uuid' || propName === 'uid')
|
|
89
|
-
return `
|
|
107
|
+
return `(string) \\Illuminate\\Support\\Str::uuid()`;
|
|
108
|
+
if (tokens.includes('ip'))
|
|
109
|
+
return `fake()->ipv4()`;
|
|
90
110
|
if (lower.includes('email'))
|
|
91
|
-
return `
|
|
111
|
+
return `fake()->unique()->safeEmail()`;
|
|
92
112
|
if (lower.includes('phone'))
|
|
93
|
-
return `
|
|
113
|
+
return `fake()->phoneNumber()`;
|
|
94
114
|
if (lower.includes('image') || lower.includes('photo') || lower.includes('avatar'))
|
|
95
|
-
return `
|
|
115
|
+
return `fake()->imageUrl()`;
|
|
96
116
|
if (lower.includes('url') || lower.includes('website'))
|
|
97
|
-
return `
|
|
117
|
+
return `fake()->url()`;
|
|
98
118
|
if (lower.includes('path') || lower.includes('file'))
|
|
99
|
-
return `'
|
|
119
|
+
return `'uploads/'.fake()->uuid().'.jpg'`;
|
|
100
120
|
if (propName === 'name' || propName === 'title')
|
|
101
|
-
return `
|
|
121
|
+
return `fake()->sentence(3)`;
|
|
102
122
|
if (lower.includes('name'))
|
|
103
|
-
return `
|
|
123
|
+
return `fake()->name()`;
|
|
104
124
|
if (lower.includes('address'))
|
|
105
|
-
return `
|
|
125
|
+
return `fake()->address()`;
|
|
106
126
|
if (lower.includes('city'))
|
|
107
|
-
return `
|
|
127
|
+
return `fake()->city()`;
|
|
108
128
|
if (lower.includes('country'))
|
|
109
|
-
return `
|
|
129
|
+
return `fake()->country()`;
|
|
110
130
|
if (lower.includes('zip') || lower.includes('postal'))
|
|
111
|
-
return `
|
|
131
|
+
return `fake()->postcode()`;
|
|
112
132
|
if (lower.includes('color'))
|
|
113
|
-
return `
|
|
133
|
+
return `fake()->hexColor()`;
|
|
114
134
|
if (lower.includes('token') || lower.includes('secret') || lower.includes('key'))
|
|
115
|
-
return
|
|
135
|
+
return `\\Illuminate\\Support\\Str::random(32)`;
|
|
116
136
|
if (lower.includes('code'))
|
|
117
|
-
return `
|
|
137
|
+
return `fake()->unique()->regexify('[A-Z0-9]{8}')`;
|
|
118
138
|
const length = property['length'];
|
|
119
139
|
if (length != null && length <= 50)
|
|
120
|
-
return `
|
|
121
|
-
return `
|
|
140
|
+
return `fake()->words(3, true)`;
|
|
141
|
+
return `fake()->sentence()`;
|
|
142
|
+
}
|
|
143
|
+
/** Largest value a `Decimal(precision, scale)` column accepts (all-9s at that precision/scale). */
|
|
144
|
+
function decimalMax(precision, scale) {
|
|
145
|
+
const digits = '9'.repeat(Math.max(precision, 1));
|
|
146
|
+
if (scale <= 0)
|
|
147
|
+
return digits;
|
|
148
|
+
const intPart = digits.slice(0, digits.length - scale) || '0';
|
|
149
|
+
const fracPart = digits.slice(digits.length - scale);
|
|
150
|
+
return `${intPart}.${fracPart}`;
|
|
151
|
+
}
|
|
152
|
+
function decimalFaker(snakeName, property) {
|
|
153
|
+
// Laravel's default Decimal migration is `decimal(8, 2)` (blueprint.go),
|
|
154
|
+
// matched here so an unspecified precision/scale keeps generating the same
|
|
155
|
+
// range as before.
|
|
156
|
+
const precision = property['precision'] ?? 8;
|
|
157
|
+
const scale = property['scale'] ?? 2;
|
|
158
|
+
const max = decimalMax(precision, scale);
|
|
159
|
+
return `'${snakeName}' => fake()->randomFloat(${scale}, 0, ${max}),`;
|
|
122
160
|
}
|
|
123
161
|
function intFaker(snakeName, property) {
|
|
124
162
|
const isUnique = property['unique'] ?? false;
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* warning is printed so the user can decide whether to clean up by hand.
|
|
20
20
|
*/
|
|
21
21
|
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
22
|
-
import { join, resolve, basename } from 'node:path';
|
|
22
|
+
import { join, relative, resolve, basename } from 'node:path';
|
|
23
|
+
import { resolveModularBaseNamespace } from './types.js';
|
|
23
24
|
/** Matches the header emitted by `generateEditableService` in service-generator.ts */
|
|
24
25
|
export const OMNIFY_EDITABLE_SERVICE_MARKER = 'SAFE TO EDIT - This file is never overwritten by Omnify.';
|
|
25
26
|
const EXTENDS_SERVICE_BASE_RE = /extends\s+[\w\\]+ServiceBase\b/;
|
|
@@ -60,34 +61,83 @@ function collectModularLayerDirs(configDir, config, category) {
|
|
|
60
61
|
for (const entry of readdirSync(modulesAbs)) {
|
|
61
62
|
const layerDir = join(modulesAbs, entry, category);
|
|
62
63
|
if (existsSync(layerDir) && statSync(layerDir).isDirectory()) {
|
|
63
|
-
dirs.push(
|
|
64
|
+
dirs.push({
|
|
65
|
+
dir: layerDir,
|
|
66
|
+
namespace: resolveModularBaseNamespace(config, entry, category, ''),
|
|
67
|
+
});
|
|
64
68
|
}
|
|
65
69
|
}
|
|
66
70
|
return dirs;
|
|
67
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* FQCN a base file claims from its LOCATION: the layer root's namespace,
|
|
74
|
+
* one segment per group subdirectory, then the class name from the file
|
|
75
|
+
* name (`Base/Crm/FooRequestBase.php` under `App\...\Base` →
|
|
76
|
+
* `App\...\Base\Crm\FooRequestBase`). Derived relative to the layer
|
|
77
|
+
* root, never from the bare basename — a grouped file's group segment
|
|
78
|
+
* is part of its identity, and it is also what hand-written `use`
|
|
79
|
+
* imports name (#263).
|
|
80
|
+
*/
|
|
81
|
+
function fqcnFromLayerRelativePath(rootNamespace, relPath) {
|
|
82
|
+
const segments = relPath.split(/[\\/]/).filter((s) => s.length > 0);
|
|
83
|
+
const fileName = segments.pop();
|
|
84
|
+
if (!fileName)
|
|
85
|
+
return rootNamespace;
|
|
86
|
+
return [rootNamespace, ...segments, fileName.replace(/\.php$/i, '')]
|
|
87
|
+
.filter((s) => s.length > 0)
|
|
88
|
+
.join('\\');
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Recursively visit every file under `root`. Directories are entered
|
|
92
|
+
* but never removed — the surrounding cleanup (pruneOrphanServiceFiles,
|
|
93
|
+
* walkPhpFiles) deletes files only and leaves emptied folders behind,
|
|
94
|
+
* and this prune matches that behaviour.
|
|
95
|
+
*/
|
|
96
|
+
function walkLayerFiles(root, visit) {
|
|
97
|
+
if (!existsSync(root))
|
|
98
|
+
return;
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = readdirSync(root);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const full = join(root, entry);
|
|
108
|
+
let info;
|
|
109
|
+
try {
|
|
110
|
+
info = statSync(full);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (info.isDirectory()) {
|
|
116
|
+
walkLayerFiles(full, visit);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
visit(full);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
68
123
|
function pruneDisabledLayerBasesInRoots(spec, roots, expectedAbs, referenced) {
|
|
69
124
|
let pruned = 0;
|
|
70
125
|
let kept = 0;
|
|
126
|
+
// Grouped schemas nest their bases one directory per group
|
|
127
|
+
// (`app/Omnify/Requests/OmnifyBase/Crm/FooRequestBase.php`), so the
|
|
128
|
+
// walk must recurse — a one-level `readdirSync` leaves every grouped
|
|
129
|
+
// stale base on disk (#263).
|
|
71
130
|
for (const root of roots) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
continue;
|
|
77
|
-
const fullPath = join(root, entry);
|
|
131
|
+
walkLayerFiles(root.dir, (fullPath) => {
|
|
132
|
+
const name = basename(fullPath);
|
|
133
|
+
if (!spec.matchesBaseFile(name))
|
|
134
|
+
return;
|
|
78
135
|
if (expectedAbs.has(fullPath))
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
content = readFileSync(fullPath, 'utf-8');
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
const fqcn = declaredClassName(content);
|
|
88
|
-
if (fqcn && referenced.has(fqcn)) {
|
|
136
|
+
return;
|
|
137
|
+
const fqcn = fqcnFromLayerRelativePath(root.namespace, relative(root.dir, fullPath));
|
|
138
|
+
if (referenced.has(fqcn)) {
|
|
89
139
|
kept += 1;
|
|
90
|
-
|
|
140
|
+
return;
|
|
91
141
|
}
|
|
92
142
|
try {
|
|
93
143
|
unlinkSync(fullPath);
|
|
@@ -96,7 +146,7 @@ function pruneDisabledLayerBasesInRoots(spec, roots, expectedAbs, referenced) {
|
|
|
96
146
|
catch {
|
|
97
147
|
// skip uncleanable files
|
|
98
148
|
}
|
|
99
|
-
}
|
|
149
|
+
});
|
|
100
150
|
}
|
|
101
151
|
return { pruned, kept };
|
|
102
152
|
}
|
|
@@ -115,7 +165,7 @@ export function pruneDisabledScaffoldLayerBases(configDir, config, generated, re
|
|
|
115
165
|
continue;
|
|
116
166
|
const roots = [
|
|
117
167
|
...collectModularLayerDirs(configDir, config, spec.modularCategory),
|
|
118
|
-
resolve(configDir, spec.legacyBasePath),
|
|
168
|
+
{ dir: resolve(configDir, spec.legacyBasePath), namespace: spec.layerConfig.baseNamespace },
|
|
119
169
|
];
|
|
120
170
|
const { pruned, kept } = pruneDisabledLayerBasesInRoots(spec, roots, expectedAbs, referenced);
|
|
121
171
|
if (pruned > 0 || kept > 0) {
|
|
@@ -3,7 +3,32 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName } from './naming-helper.js';
|
|
5
5
|
import { toStoreRules, toUpdateRules, formatRules, resolveFkRuleType } from './type-mapper.js';
|
|
6
|
-
import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
|
|
6
|
+
import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup, resolveGlobalEnumNamespace } from './types.js';
|
|
7
|
+
import { enumClassName } from './enum-generator.js';
|
|
8
|
+
/**
|
|
9
|
+
* Format rules for an EnumRef property, replacing the generic `'string'` rule
|
|
10
|
+
* `toStoreRules`/`toUpdateRules` emit for EnumRef with `Rule::enum(<Class>::class)`
|
|
11
|
+
* — bare `'string'` lets any value through validation while the model cast
|
|
12
|
+
* and migration column both enforce the enum's set (#265). Duplicates just
|
|
13
|
+
* enough of `formatRules`' string/rule_unique_ignore branches, since the
|
|
14
|
+
* `Rule::enum(...)` case can't be added to type-mapper.ts's `Rule` union from
|
|
15
|
+
* here without editing a file this job does not own.
|
|
16
|
+
*/
|
|
17
|
+
function formatEnumRefRules(rules, enumFqcn) {
|
|
18
|
+
const parts = [];
|
|
19
|
+
for (const rule of rules) {
|
|
20
|
+
if (rule === 'string') {
|
|
21
|
+
parts.push(`Rule::enum(${enumFqcn}::class)`);
|
|
22
|
+
}
|
|
23
|
+
else if (typeof rule === 'string') {
|
|
24
|
+
parts.push(`'${rule}'`);
|
|
25
|
+
}
|
|
26
|
+
else if (Array.isArray(rule) && rule[0] === 'rule_unique_ignore') {
|
|
27
|
+
parts.push(`Rule::unique('${rule[1]}')->ignore($this->route('${rule[2]}'))`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return `[${parts.join(', ')}]`;
|
|
31
|
+
}
|
|
7
32
|
/** Generate Store/Update request classes for all project-owned visible object schemas. */
|
|
8
33
|
export function generateRequests(reader, config) {
|
|
9
34
|
if (!config.requests.enabled)
|
|
@@ -142,7 +167,21 @@ function generateBaseRequest(name, schema, reader, config, action) {
|
|
|
142
167
|
const rules = isUpdate
|
|
143
168
|
? toUpdateRules(prop, tableName, modelRouteParam)
|
|
144
169
|
: toStoreRules(prop, tableName);
|
|
145
|
-
|
|
170
|
+
// EnumRef: validate against the enum's actual value set via Rule::enum()
|
|
171
|
+
// instead of the bare 'string' toStoreRules/toUpdateRules emit for it —
|
|
172
|
+
// otherwise an out-of-set string passes validation, hits the DB, and only
|
|
173
|
+
// explodes later when the model cast reads it back (#265). Use the exact
|
|
174
|
+
// FQCN the model cast/doc-block already use (model-generator.ts's
|
|
175
|
+
// buildCasts), so validation and the cast never disagree. Falls back to
|
|
176
|
+
// the plain rules when the referenced enum schema doesn't exist, matching
|
|
177
|
+
// toStoreRules' own fallback for that case.
|
|
178
|
+
const enumName = type === 'EnumRef' ? prop['enum'] : undefined;
|
|
179
|
+
const enumSchema = typeof enumName === 'string' ? reader.getSchema(enumName) : undefined;
|
|
180
|
+
const globalEnumNs = type === 'EnumRef' ? resolveGlobalEnumNamespace(config, config.models.namespace) : '';
|
|
181
|
+
const formattedRules = typeof enumName === 'string' && enumSchema?.kind === 'enum' && globalEnumNs
|
|
182
|
+
? formatEnumRefRules(rules, `\\${globalEnumNs}\\${enumClassName(enumName)}`)
|
|
183
|
+
: formatRules(rules);
|
|
184
|
+
rulesLines.push(` '${snakeName}' => ${formattedRules},`);
|
|
146
185
|
attributeKeys.push(snakeName);
|
|
147
186
|
// Translatable: add locale-nested rules (Astrotomic expects {locale}.{field})
|
|
148
187
|
const isTranslatable = prop['translatable'] ?? false;
|