@omnifyjp/ts 7.0.2 → 7.0.3

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.
@@ -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 `'${snakeName}' => fake()->randomFloat(2, 1, 10000),`;
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 `'${snakeName}' => fake()->unique()->slug(2),`;
105
+ return `fake()->unique()->slug(2)`;
88
106
  if (propName === 'uuid' || propName === 'uid')
89
- return `'${snakeName}' => (string) \\Illuminate\\Support\\Str::uuid(),`;
107
+ return `(string) \\Illuminate\\Support\\Str::uuid()`;
108
+ if (tokens.includes('ip'))
109
+ return `fake()->ipv4()`;
90
110
  if (lower.includes('email'))
91
- return `'${snakeName}' => fake()->unique()->safeEmail(),`;
111
+ return `fake()->unique()->safeEmail()`;
92
112
  if (lower.includes('phone'))
93
- return `'${snakeName}' => fake()->phoneNumber(),`;
113
+ return `fake()->phoneNumber()`;
94
114
  if (lower.includes('image') || lower.includes('photo') || lower.includes('avatar'))
95
- return `'${snakeName}' => fake()->imageUrl(),`;
115
+ return `fake()->imageUrl()`;
96
116
  if (lower.includes('url') || lower.includes('website'))
97
- return `'${snakeName}' => fake()->url(),`;
117
+ return `fake()->url()`;
98
118
  if (lower.includes('path') || lower.includes('file'))
99
- return `'${snakeName}' => 'uploads/'.fake()->uuid().'.jpg',`;
119
+ return `'uploads/'.fake()->uuid().'.jpg'`;
100
120
  if (propName === 'name' || propName === 'title')
101
- return `'${snakeName}' => fake()->sentence(3),`;
121
+ return `fake()->sentence(3)`;
102
122
  if (lower.includes('name'))
103
- return `'${snakeName}' => fake()->name(),`;
123
+ return `fake()->name()`;
104
124
  if (lower.includes('address'))
105
- return `'${snakeName}' => fake()->address(),`;
125
+ return `fake()->address()`;
106
126
  if (lower.includes('city'))
107
- return `'${snakeName}' => fake()->city(),`;
127
+ return `fake()->city()`;
108
128
  if (lower.includes('country'))
109
- return `'${snakeName}' => fake()->country(),`;
129
+ return `fake()->country()`;
110
130
  if (lower.includes('zip') || lower.includes('postal'))
111
- return `'${snakeName}' => fake()->postcode(),`;
131
+ return `fake()->postcode()`;
112
132
  if (lower.includes('color'))
113
- return `'${snakeName}' => fake()->hexColor(),`;
133
+ return `fake()->hexColor()`;
114
134
  if (lower.includes('token') || lower.includes('secret') || lower.includes('key'))
115
- return `'${snakeName}' => \\Illuminate\\Support\\Str::random(32),`;
135
+ return `\\Illuminate\\Support\\Str::random(32)`;
116
136
  if (lower.includes('code'))
117
- return `'${snakeName}' => fake()->unique()->regexify('[A-Z0-9]{8}'),`;
137
+ return `fake()->unique()->regexify('[A-Z0-9]{8}')`;
118
138
  const length = property['length'];
119
139
  if (length != null && length <= 50)
120
- return `'${snakeName}' => fake()->words(3, true),`;
121
- return `'${snakeName}' => fake()->sentence(),`;
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(layerDir);
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
- if (!existsSync(root))
73
- continue;
74
- for (const entry of readdirSync(root)) {
75
- if (!spec.matchesBaseFile(entry))
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
- continue;
80
- let content = '';
81
- try {
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
- continue;
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
- rulesLines.push(` '${snakeName}' => ${formatRules(rules)},`);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "7.0.2",
3
+ "version": "7.0.3",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",