@omnifyjp/ts 6.7.13 → 6.7.15

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.
@@ -32,6 +32,10 @@ function generateEnumClass(name, schema, config) {
32
32
  const values = (schema.values ?? []);
33
33
  const cases = values.map(v => buildCase(v));
34
34
  const labelsConst = buildLabelsConstant(values);
35
+ const allValuesHaveLabels = values.every((value) => typeof value.label === 'string' || Object.keys(value.label ?? {}).length > 0);
36
+ const labelThirdFallback = allValuesHaveLabels
37
+ ? '$labels[array_key_first($labels)]'
38
+ : '(array_values($labels)[0] ?? $this->value)';
35
39
  const content = `<?php
36
40
 
37
41
  namespace ${namespace};
@@ -59,12 +63,11 @@ ${labelsConst}
59
63
  public function label(?string $locale = null): string
60
64
  {
61
65
  $locale = $locale ?? app()->getLocale();
62
- $labels = self::LABELS[$this->value] ?? [];
66
+ $labels = self::LABELS[$this->value];
63
67
 
64
68
  return $labels[$locale]
65
69
  ?? $labels[config('app.fallback_locale', 'en')]
66
- ?? $labels[array_key_first($labels) ?? 'en']
67
- ?? $this->value;
70
+ ?? ${labelThirdFallback};
68
71
  }
69
72
 
70
73
  /**
@@ -122,18 +122,48 @@ class CleanupFiles extends Command
122
122
  }
123
123
 
124
124
  if ($dryRun) {
125
+ $skippedShared = 0;
126
+ // Each row is counted independently; the real run force-deletes sequentially so
127
+ // earlier rows stop counting as references — this total is an upper bound.
128
+ $query->chunkById(100, function ($files) use (&$skippedShared) {
129
+ foreach ($files as $file) {
130
+ if ($file->path && File::withTrashed()
131
+ ->where('disk', $file->disk)
132
+ ->where('path', $file->path)
133
+ ->where('id', '!=', $file->id)
134
+ ->exists()) {
135
+ $skippedShared++;
136
+ }
137
+ }
138
+ });
125
139
  $this->info("[Phase 2] Would purge {$count} file(s) older than {$hours}h.");
140
+ if ($skippedShared > 0) {
141
+ $this->info("[Phase 2] Would skip up to {$skippedShared} physical delete(s) — object may still be referenced by another row.");
142
+ }
126
143
  return self::SUCCESS;
127
144
  }
128
145
 
129
146
  $purged = 0;
130
147
  $errors = 0;
148
+ $skippedShared = 0;
131
149
 
132
- $query->chunkById(100, function ($files) use (&$purged, &$errors) {
150
+ $query->chunkById(100, function ($files) use (&$purged, &$errors, &$skippedShared) {
133
151
  foreach ($files as $file) {
134
152
  try {
135
- if ($file->path && Storage::disk($file->disk)->exists($file->path)) {
136
- Storage::disk($file->disk)->delete($file->path);
153
+ // Rows force-deleted earlier in this batch no longer appear in withTrashed(),
154
+ // so the last row sharing a disk+path deletes the object without extra bookkeeping.
155
+ if ($file->path) {
156
+ $pathReferencedElsewhere = File::withTrashed()
157
+ ->where('disk', $file->disk)
158
+ ->where('path', $file->path)
159
+ ->where('id', '!=', $file->id)
160
+ ->exists();
161
+
162
+ if (! $pathReferencedElsewhere && Storage::disk($file->disk)->exists($file->path)) {
163
+ Storage::disk($file->disk)->delete($file->path);
164
+ } elseif ($pathReferencedElsewhere) {
165
+ $skippedShared++;
166
+ }
137
167
  }
138
168
  $file->forceDelete();
139
169
  $purged++;
@@ -144,7 +174,13 @@ class CleanupFiles extends Command
144
174
  }
145
175
  });
146
176
 
147
- $this->info("[Phase 2] Purged {$purged} file(s). Physical files removed.");
177
+ $purgeMsg = "[Phase 2] Purged {$purged} file(s).";
178
+ if ($skippedShared > 0) {
179
+ $purgeMsg .= " Skipped {$skippedShared} physical delete(s) — object still referenced by another row.";
180
+ } else {
181
+ $purgeMsg .= ' Physical files removed.';
182
+ }
183
+ $this->info($purgeMsg);
148
184
  if ($errors > 0) {
149
185
  $this->warn("{$errors} file(s) failed to purge.");
150
186
  }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName, pluralize } from './naming-helper.js';
5
5
  import { resolveTimestamps } from './timestamps.js';
6
- import { toCast, toPhpDocType, isHiddenByDefault } from './type-mapper.js';
6
+ import { toCast, toPhpDocType, isHiddenByDefault, resolveFkCast, resolveFkPhpDocType } from './type-mapper.js';
7
7
  import { buildRelation } from './relation-builder.js';
8
8
  import { shadowingPropertyConflicts } from './shadowing-conflicts.js';
9
9
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, resolveGlobalEnumNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup, } from './types.js';
@@ -451,7 +451,7 @@ function generateUserModel(name, schema, reader, config, hasNestedSet = false) {
451
451
  if (hasNestedSet) {
452
452
  imports.push(`use ${nestedSetNamespace}\\NodeTrait;`);
453
453
  }
454
- const traits = [' use HasFactory;'];
454
+ const traits = [` /** @use HasFactory<${modelName}Factory> */\n use HasFactory;`];
455
455
  if (hasNestedSet) {
456
456
  traits.push(' use NodeTrait;');
457
457
  }
@@ -614,7 +614,8 @@ function buildDocProperties(properties, expandedProperties, propertyOrder, reade
614
614
  // doesn't actually have.
615
615
  const snakeName = prop['column'] || toFkColumnName(propName);
616
616
  const nullable = prop['nullable'] ?? false;
617
- const phpType = nullable ? 'int|null' : 'int';
617
+ const target = prop['target'] ?? '';
618
+ const phpType = resolveFkPhpDocType(prop, target, reader, nullable);
618
619
  lines.push(` * @property ${phpType} $${snakeName}`);
619
620
  }
620
621
  else if (relation === 'MorphTo') {
@@ -808,13 +809,10 @@ function buildCasts(properties, expandedProperties, propertyOrder, reader, confi
808
809
  const relation = prop['relation'] ?? '';
809
810
  if (relation === 'ManyToOne') {
810
811
  const target = prop['target'] ?? '';
811
- const targetSchema = reader.getSchema(target);
812
- const targetIdType = typeof targetSchema?.options?.id === 'string'
813
- ? targetSchema.options.id
814
- : 'BigInt';
815
- if (targetIdType !== 'Uuid' && targetIdType !== 'Ulid') {
812
+ const fkCast = resolveFkCast(prop, target, reader);
813
+ if (fkCast) {
816
814
  // omnify-go#166: honor the `column:` override (ForeignColumn).
817
- casts.push(`'${prop['column'] || toFkColumnName(propName)}' => 'integer',`);
815
+ casts.push(`'${prop['column'] || toFkColumnName(propName)}' => '${fkCast}',`);
818
816
  }
819
817
  }
820
818
  continue;
@@ -934,7 +932,14 @@ function buildRelations(schemaName, properties, propertyOrder, modelNamespace, r
934
932
  : toSnakeCase(propName));
935
933
  }
936
934
  }
937
- const result = buildRelation(propName, prop, ns, { sourceTableName, targetTableName, pivotProperties, pivotTableName });
935
+ const result = buildRelation(propName, prop, ns, {
936
+ sourceTableName,
937
+ targetTableName,
938
+ pivotProperties,
939
+ pivotTableName,
940
+ reader,
941
+ sourceSchemaName: schemaName,
942
+ });
938
943
  if (result) {
939
944
  methods.push('\n' + result.method);
940
945
  declaredMethods.add(toCamelCase(propName));
@@ -1054,6 +1059,7 @@ function buildInverseRelations(parentName, modelNamespace, reader, declaredMetho
1054
1059
  methods.push(`
1055
1060
  /**
1056
1061
  * Auto-generated inverse of ${childName}.${childPropName} (${relation}).
1062
+ * @return ${returnType}<${fqcn}, $this>
1057
1063
  */
1058
1064
  public function ${methodName}(): ${returnType}
1059
1065
  {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Port of RelationBuilder.php — generates Eloquent relation methods.
3
3
  */
4
+ import type { SchemaReader } from './schema-reader.js';
4
5
  interface RelationResult {
5
6
  method: string;
6
7
  imports: string[];
@@ -8,6 +9,9 @@ interface RelationResult {
8
9
  interface RelationContext {
9
10
  sourceTableName: string;
10
11
  targetTableName: string;
12
+ /** Owning schema name (PascalCase) for OneToMany FK resolution. Issue #237. */
13
+ sourceSchemaName?: string;
14
+ reader?: SchemaReader;
11
15
  /** Non-conventional physical table resolved from an explicit pivot schema.
12
16
  * Omitted when Laravel can infer the joining table from model basenames. */
13
17
  pivotTableName?: string;
@@ -24,6 +28,13 @@ export declare function buildRelation(propName: string, property: Record<string,
24
28
  export declare function generatePivotTableName(sourceModel: string, targetModel: string): string;
25
29
  /** Get the return type hint for a relation. */
26
30
  export declare function getReturnType(relation: string): string;
31
+ /**
32
+ * Resolve the child-table FK column for a OneToMany when `mappedBy` / `inversedBy`
33
+ * is absent. Prefer the single inverse ManyToOne on the target (omnify-go#166
34
+ * `column:` override included); if zero or multiple matches, fall back to the
35
+ * owning schema name — not the plural property name. Issue #237.
36
+ */
37
+ export declare function resolveHasManyForeignKey(_propName: string, target: string, context?: RelationContext): string;
27
38
  /**
28
39
  * Build a chained `->orderBy('col', 'dir')` PHP fragment from an OrderByList.
29
40
  * Returns an empty string if no ordering is configured. Issue #40.
@@ -16,7 +16,7 @@ export function buildRelation(propName, property, modelNamespace, context) {
16
16
  method = oneToOne(propName, property, target, modelNamespace);
17
17
  break;
18
18
  case 'OneToMany':
19
- method = hasMany(propName, property, target, modelNamespace);
19
+ method = hasMany(propName, property, target, modelNamespace, context);
20
20
  break;
21
21
  case 'ManyToMany':
22
22
  method = belongsToMany(propName, property, target, modelNamespace, context);
@@ -39,6 +39,11 @@ export function buildRelation(propName, property, modelNamespace, context) {
39
39
  }
40
40
  if (method === null)
41
41
  return null;
42
+ const type = getReturnType(relation);
43
+ const related = relation === 'MorphTo'
44
+ ? '\\Illuminate\\Database\\Eloquent\\Model'
45
+ : '\\' + modelNamespace + '\\' + target;
46
+ method = method.replace(' */', ` * @return ${type}<${related}, $this>\n */`);
42
47
  return { method, imports };
43
48
  }
44
49
  /** Generate the table name Laravel's joiningTable() infers from model basenames. */
@@ -92,9 +97,11 @@ function oneToOne(propName, property, target, ns) {
92
97
  }
93
98
  return belongsTo(propName, property, target, ns);
94
99
  }
95
- function hasMany(propName, property, target, ns) {
100
+ function hasMany(propName, property, target, ns, context) {
96
101
  const inverse = property['mappedBy'] ?? property['inversedBy'] ?? null;
97
- const foreignKey = inverse ? toFkColumnName(inverse) : toFkColumnName(propName);
102
+ const foreignKey = inverse
103
+ ? toFkColumnName(inverse)
104
+ : resolveHasManyForeignKey(propName, target, context);
98
105
  const methodName = toCamelCase(propName);
99
106
  const fqcn = `\\${ns}\\${target}`;
100
107
  const orderChain = formatOrderByChain(property['orderBy']);
@@ -106,6 +113,39 @@ function hasMany(propName, property, target, ns) {
106
113
  return $this->hasMany(${fqcn}::class, '${foreignKey}')${orderChain};
107
114
  }`;
108
115
  }
116
+ /**
117
+ * Resolve the child-table FK column for a OneToMany when `mappedBy` / `inversedBy`
118
+ * is absent. Prefer the single inverse ManyToOne on the target (omnify-go#166
119
+ * `column:` override included); if zero or multiple matches, fall back to the
120
+ * owning schema name — not the plural property name. Issue #237.
121
+ */
122
+ export function resolveHasManyForeignKey(_propName, target, context) {
123
+ const reader = context?.reader;
124
+ const owningSchemaName = context?.sourceSchemaName;
125
+ if (reader && owningSchemaName && target) {
126
+ const matches = findInverseManyToOneFkColumns(reader, target, owningSchemaName);
127
+ if (matches.length === 1) {
128
+ return matches[0];
129
+ }
130
+ return toFkColumnName(owningSchemaName);
131
+ }
132
+ return toFkColumnName(_propName);
133
+ }
134
+ function findInverseManyToOneFkColumns(reader, targetSchemaName, owningSchemaName) {
135
+ const targetSchema = reader.getSchema(targetSchemaName);
136
+ const props = (targetSchema?.properties ?? {});
137
+ const columns = [];
138
+ for (const [childPropName, childProp] of Object.entries(props)) {
139
+ if (childProp['type'] !== 'Association')
140
+ continue;
141
+ if (childProp['relation'] !== 'ManyToOne')
142
+ continue;
143
+ if (childProp['target'] !== owningSchemaName)
144
+ continue;
145
+ columns.push(childProp['column'] || toFkColumnName(childPropName));
146
+ }
147
+ return columns;
148
+ }
109
149
  /**
110
150
  * Build a chained `->orderBy('col', 'dir')` PHP fragment from an OrderByList.
111
151
  * Returns an empty string if no ordering is configured. Issue #40.
@@ -2,26 +2,8 @@
2
2
  * Port of RequestGenerator.php — generates Store/Update request classes.
3
3
  */
4
4
  import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName } from './naming-helper.js';
5
- import { toStoreRules, toUpdateRules, formatRules } from './type-mapper.js';
5
+ import { toStoreRules, toUpdateRules, formatRules, resolveFkRuleType } from './type-mapper.js';
6
6
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
7
- /**
8
- * Resolve the Laravel validation rule type ('integer', 'uuid', or 'string') for
9
- * a foreign key column based on the target schema's primary key type.
10
- *
11
- * Mirrors the FK cast logic in `model-generator.ts` so validation rules and
12
- * Eloquent casts agree about the wire format. Issue #36.
13
- */
14
- function resolveFkRuleType(target, reader) {
15
- if (!target)
16
- return 'integer';
17
- const targetSchema = reader.getSchema(target);
18
- const id = targetSchema?.options?.id;
19
- if (id === 'Uuid')
20
- return 'uuid';
21
- if (id === 'Ulid' || id === 'String')
22
- return 'string';
23
- return 'integer';
24
- }
25
7
  /** Generate Store/Update request classes for all project-owned visible object schemas. */
26
8
  export function generateRequests(reader, config) {
27
9
  const files = [];
@@ -105,7 +87,7 @@ function generateBaseRequest(name, schema, reader, config, action) {
105
87
  if (relation === 'ManyToOne') {
106
88
  const snakeName = toFkColumnName(propName);
107
89
  const target = prop['target'] ?? '';
108
- const targetIdType = resolveFkRuleType(target, reader);
90
+ const targetIdType = resolveFkRuleType(prop, target, reader);
109
91
  const rules = isUpdate
110
92
  ? toUpdateRules(prop, tableName, modelRouteParam, targetIdType)
111
93
  : toStoreRules(prop, tableName, targetIdType);
@@ -180,6 +162,58 @@ function generateBaseRequest(name, schema, reader, config, action) {
180
162
  const localesClass = `${modelLocalesNamespace}\\${modelName}Locales`;
181
163
  const attributeKeysList = attributeKeys.map(k => ` '${k}',`).join('\n');
182
164
  const needsRuleImport = rulesContent.includes('Rule::');
165
+ // WHICH KEYS THE LOCALES CLASS WILL ACTUALLY HOLD, which is not the same
166
+ // question as which keys exist. `locales-generator` skips every Association
167
+ // outright, while this generator pushes each ManyToOne's FK column into
168
+ // `attributeKeys` — so a schema whose properties are ALL associations gets a
169
+ // non-empty key list and an EMPTY PROPERTY_DISPLAY_NAMES, and the loop below
170
+ // then reads offsets off `array{}`: ten unfixable PHPStan errors per such schema.
171
+ //
172
+ // Measured twice. `PlanItem` hit it first and was dropped for an unrelated
173
+ // reason, which hid it; `PlanMembership` hit it again and could not be
174
+ // dropped, because a self-referential many-to-many is REFUSED as a pivot —
175
+ // omnify itself says "declare a regular `kind: object` schema with two
176
+ // ManyToOne associations". So it is a shape the generator prescribes and
177
+ // then cannot generate for.
178
+ //
179
+ // Fixing the LOCALES side instead — emitting association display names
180
+ // under their FK column — was tried and reverted: it is better semantically
181
+ // (a validation error would say 計画課題 rather than `plan_issue_id`) but it
182
+ // populates the constant for every schema in the repo, which makes PHPStan
183
+ // prove the `??` fallback chain dead and turns 0 errors into 317. Worth
184
+ // doing upstream, where the generated body can change with it. Not here.
185
+ const hasNamedAttributes = Object.values(properties).some((candidate) => (candidate?.type ?? 'String') !== 'Association');
186
+ const attributesBody = attributeKeys.length === 0 || !hasNamedAttributes
187
+ ? [
188
+ ' protected function schemaAttributes(): array',
189
+ ' {',
190
+ ' return [];',
191
+ ' }',
192
+ ].join('\n')
193
+ : [
194
+ ' protected function schemaAttributes(): array',
195
+ ' {',
196
+ " $locale = app()->getLocale();",
197
+ " $fallback = config('app.fallback_locale', 'en');",
198
+ ` $propertyNames = ${modelName}Locales::PROPERTY_DISPLAY_NAMES;`,
199
+ ' $attributes = [];',
200
+ '',
201
+ ' $keys = [',
202
+ attributeKeysList,
203
+ ' ];',
204
+ '',
205
+ ' foreach ($keys as $key) {',
206
+ ' $names = $propertyNames[$key] ?? [];',
207
+ " $attributes[$key] = $names[$locale] ?? $names[$fallback] ?? $names[array_key_first($names) ?? 'en'] ?? $key;",
208
+ ' }',
209
+ '',
210
+ ' return $attributes;',
211
+ ' }',
212
+ ].join('\n');
213
+ // Only the non-empty body references the Locales class.
214
+ const localesImport = attributeKeys.length === 0 || !hasNamedAttributes
215
+ ? ''
216
+ : `\nuse ${localesClass};`;
183
217
  const ruleImport = needsRuleImport ? '\nuse Illuminate\\Validation\\Rule;' : '';
184
218
  const content = `<?php
185
219
 
@@ -192,8 +226,7 @@ namespace ${baseNamespace};
192
226
  * @generated by omnify
193
227
  */
194
228
 
195
- use Illuminate\\Foundation\\Http\\FormRequest;${ruleImport}
196
- use ${localesClass};
229
+ use Illuminate\\Foundation\\Http\\FormRequest;${ruleImport}${localesImport}
197
230
 
198
231
  class ${modelName}${action}RequestBase extends FormRequest
199
232
  {
@@ -229,7 +262,7 @@ class ${modelName}${action}RequestBase extends FormRequest
229
262
  /**
230
263
  * Get the schema-defined validation rules.
231
264
  *
232
- * @return array<string, array>
265
+ * @return array<string, array<mixed>>
233
266
  */
234
267
  protected function schemaRules(): array
235
268
  {
@@ -244,24 +277,7 @@ ${rulesContent}
244
277
  *
245
278
  * @return array<string, string>
246
279
  */
247
- protected function schemaAttributes(): array
248
- {
249
- $locale = app()->getLocale();
250
- $fallback = config('app.fallback_locale', 'en');
251
- $propertyNames = ${modelName}Locales::PROPERTY_DISPLAY_NAMES;
252
- $attributes = [];
253
-
254
- $keys = [
255
- ${attributeKeysList}
256
- ];
257
-
258
- foreach ($keys as $key) {
259
- $names = $propertyNames[$key] ?? [];
260
- $attributes[$key] = $names[$locale] ?? $names[$fallback] ?? $names[array_key_first($names) ?? 'en'] ?? $key;
261
- }
262
-
263
- return $attributes;
264
- }
280
+ ${attributesBody}
265
281
  }
266
282
  `;
267
283
  // Issue #96 + #98 v5.8+: legacy honors flatBase + group-by-folder.
@@ -165,6 +165,7 @@ namespace ${baseNamespace};
165
165
 
166
166
  ${useBlock}
167
167
 
168
+ /** @mixin \\${modelNamespace}${schema.kind === 'pivot' ? '\\Pivot' : ''}\\${modelName} */
168
169
  class ${modelName}ResourceBase extends JsonResource
169
170
  {
170
171
  /**
@@ -554,7 +554,7 @@ function generateBaseService(name, schema, reader, config) {
554
554
  // ── Build class body ──────────────────────────────────────────────────────
555
555
  const sections = [];
556
556
  // Model property
557
- sections.push(` protected string $model = ${modelName}::class;`);
557
+ sections.push(` /** @var class-string<${modelName}> */\n protected string $model = ${modelName}::class;`);
558
558
  // ── Query section ─────────────────────────────────────────────────────────
559
559
  sections.push('');
560
560
  sections.push(buildSectionComment('Query'));
@@ -595,7 +595,7 @@ function generateBaseService(name, schema, reader, config) {
595
595
  // ── Eager Load Hooks (#81) ────────────────────────────────────────────────
596
596
  sections.push('');
597
597
  sections.push(buildSectionComment('Eager Load Hooks (#81)'));
598
- sections.push(buildEagerLoadHooks());
598
+ sections.push(buildEagerLoadHooks(modelName));
599
599
  // ── Translatable helpers ──────────────────────────────────────────────────
600
600
  if (hasTranslatable) {
601
601
  sections.push('');
@@ -675,14 +675,14 @@ function buildSectionComment(title) {
675
675
  * config will be removed in a future major release per the #81
676
676
  * deprecation path.
677
677
  */
678
- function buildEagerLoadHooks() {
678
+ function buildEagerLoadHooks(modelName) {
679
679
  return `
680
680
  /**
681
681
  * Override in the editable sibling service to eager-load relations
682
682
  * on the list() query. Default: no-op. Legacy schema-level eagerLoad
683
683
  * configuration still applies before this hook runs.
684
684
  *
685
- * @param \\Illuminate\\Database\\Eloquent\\Builder $query
685
+ * @param \\Illuminate\\Database\\Eloquent\\Builder<${modelName}> $query
686
686
  */
687
687
  protected function applyListEagerLoads($query): void
688
688
  {
@@ -694,7 +694,7 @@ function buildEagerLoadHooks() {
694
694
  * Override in the editable sibling service to eager-load relations on
695
695
  * the findById() query. Default: no-op.
696
696
  *
697
- * @param \\Illuminate\\Database\\Eloquent\\Builder $query
697
+ * @param \\Illuminate\\Database\\Eloquent\\Builder<${modelName}> $query
698
698
  */
699
699
  protected function applyFindByIdEagerLoads($query): void
700
700
  {
@@ -711,7 +711,7 @@ function buildEagerLoadHooks() {
711
711
  * the lookup() query. Default: no-op. Most lookup endpoints should
712
712
  * stay lean — eager loading defeats the point of a dropdown projection.
713
713
  *
714
- * @param \\Illuminate\\Database\\Eloquent\\Builder $query
714
+ * @param \\Illuminate\\Database\\Eloquent\\Builder<${modelName}> $query
715
715
  */
716
716
  protected function applyLookupEagerLoads($query): void
717
717
  {
@@ -875,10 +875,7 @@ function buildListFilterShape(filterableFields, allowedSortColumns, hasSoftDelet
875
875
  lines.push(` * ${f.colName}?: ${phpType},`);
876
876
  }
877
877
  lines.push(` * search?: string,`);
878
- const sortUnion = allowedSortColumns.length > 0
879
- ? allowedSortColumns.flatMap(c => [`'${c}'`, `'-${c}'`]).join('|')
880
- : 'string';
881
- lines.push(` * sort?: ${sortUnion},`);
878
+ lines.push(` * sort?: string,`);
882
879
  lines.push(` * per_page?: int,`);
883
880
  if (hasSoftDelete) {
884
881
  lines.push(` * with_trashed?: bool,`);
@@ -1106,6 +1103,7 @@ function buildListMethod(modelName, searchableFields, filterableFields, sortable
1106
1103
  * @param array{
1107
1104
  ${filterShape}
1108
1105
  * } $filters
1106
+ * @return LengthAwarePaginator<int, ${modelName}>
1109
1107
  *
1110
1108
  ${listExample}
1111
1109
  */
@@ -1188,11 +1186,15 @@ function resolvePhpFilterType(type) {
1188
1186
  }
1189
1187
  function buildFilterLine(f) {
1190
1188
  if (f.translatable) {
1191
- return ` $query->when(isset($filters['${f.colName}']), fn ($q) => $q->whereTranslation('${f.colName}', $filters['${f.colName}']));`;
1189
+ return ` if (isset($filters['${f.colName}'])) {
1190
+ $query->whereTranslation('${f.colName}', $filters['${f.colName}']);
1191
+ }`;
1192
1192
  }
1193
1193
  switch (f.type) {
1194
1194
  case 'Boolean':
1195
- return ` $query->when(isset($filters['${f.colName}']), fn ($q) => $q->where('${f.colName}', $filters['${f.colName}']));`;
1195
+ return ` if (isset($filters['${f.colName}'])) {
1196
+ $query->where('${f.colName}', $filters['${f.colName}']);
1197
+ }`;
1196
1198
  default:
1197
1199
  return ` $query->when($filters['${f.colName}'] ?? null, fn ($q, $v) => $q->where('${f.colName}', $v));`;
1198
1200
  }
@@ -31,7 +31,7 @@ trait HasLocalizedDisplayName
31
31
  public static function displayName(?string $locale = null): string
32
32
  {
33
33
  $locale = $locale ?? app()->getLocale();
34
- $displayNames = static::$localizedDisplayNames ?? [];
34
+ $displayNames = static::$localizedDisplayNames;
35
35
 
36
36
  return $displayNames[$locale]
37
37
  ?? $displayNames[config('app.fallback_locale', 'en')]
@@ -46,7 +46,7 @@ trait HasLocalizedDisplayName
46
46
  */
47
47
  public static function allDisplayNames(): array
48
48
  {
49
- return static::$localizedDisplayNames ?? [];
49
+ return static::$localizedDisplayNames;
50
50
  }
51
51
 
52
52
  /**
@@ -88,7 +88,7 @@ trait HasLocalizedDisplayName
88
88
  $locale = $locale ?? app()->getLocale();
89
89
  $result = [];
90
90
 
91
- foreach (static::$localizedPropertyDisplayNames ?? [] as $property => $displayNames) {
91
+ foreach (static::$localizedPropertyDisplayNames as $property => $displayNames) {
92
92
  $result[$property] = $displayNames[$locale]
93
93
  ?? $displayNames[config('app.fallback_locale', 'en')]
94
94
  ?? $displayNames[array_key_first($displayNames) ?? 'en']
@@ -1,7 +1,21 @@
1
1
  /**
2
2
  * Port of TypeMapper.php — type mappings for PHP casts, rules, resources.
3
3
  */
4
+ import type { SchemaReader } from './schema-reader.js';
4
5
  type Rule = string | ['rule_in', string[]] | ['rule_unique_ignore', string, string];
6
+ /**
7
+ * Effective primary-key type for a ManyToOne foreign key column.
8
+ * Property-level `idType:` wins (required for targets outside the schema set),
9
+ * then the target schema's `options.id`, then an integer-family default.
10
+ * Issue #237.
11
+ */
12
+ export declare function resolveFkIdType(property: Record<string, unknown>, target: string, reader: SchemaReader): string;
13
+ /** Laravel validation rule type for a ManyToOne FK (Issue #36, #237). */
14
+ export declare function resolveFkRuleType(property: Record<string, unknown>, target: string, reader: SchemaReader): 'integer' | 'uuid' | 'string';
15
+ /** Eloquent cast for a ManyToOne FK column. */
16
+ export declare function resolveFkCast(property: Record<string, unknown>, target: string, reader: SchemaReader): 'integer' | 'string' | null;
17
+ /** `@property` doc type for a ManyToOne FK column. */
18
+ export declare function resolveFkPhpDocType(property: Record<string, unknown>, target: string, reader: SchemaReader, nullable: boolean): string;
5
19
  /** Map Omnify property type to PHP cast. */
6
20
  export declare function toCast(type: string, property?: Record<string, unknown>): string | null;
7
21
  /** Map Omnify property type to PHP doc type. */
@@ -2,6 +2,49 @@
2
2
  * Port of TypeMapper.php — type mappings for PHP casts, rules, resources.
3
3
  */
4
4
  import { toTableName } from './naming-helper.js';
5
+ /**
6
+ * Effective primary-key type for a ManyToOne foreign key column.
7
+ * Property-level `idType:` wins (required for targets outside the schema set),
8
+ * then the target schema's `options.id`, then an integer-family default.
9
+ * Issue #237.
10
+ */
11
+ export function resolveFkIdType(property, target, reader) {
12
+ const override = property['idType'];
13
+ if (typeof override === 'string' && override !== '') {
14
+ return override;
15
+ }
16
+ if (!target)
17
+ return 'BigInt';
18
+ const targetSchema = reader.getSchema(target);
19
+ const id = targetSchema?.options?.id;
20
+ if (typeof id === 'string')
21
+ return id;
22
+ return 'BigInt';
23
+ }
24
+ /** Laravel validation rule type for a ManyToOne FK (Issue #36, #237). */
25
+ export function resolveFkRuleType(property, target, reader) {
26
+ const idType = resolveFkIdType(property, target, reader);
27
+ if (idType === 'Uuid')
28
+ return 'uuid';
29
+ if (idType === 'Ulid' || idType === 'String')
30
+ return 'string';
31
+ return 'integer';
32
+ }
33
+ /** Eloquent cast for a ManyToOne FK column. */
34
+ export function resolveFkCast(property, target, reader) {
35
+ const idType = resolveFkIdType(property, target, reader);
36
+ if (idType === 'Uuid' || idType === 'Ulid' || idType === 'String')
37
+ return 'string';
38
+ if (idType === 'Int' || idType === 'BigInt' || idType === 'TinyInt')
39
+ return 'integer';
40
+ return 'integer';
41
+ }
42
+ /** `@property` doc type for a ManyToOne FK column. */
43
+ export function resolveFkPhpDocType(property, target, reader, nullable) {
44
+ const idType = resolveFkIdType(property, target, reader);
45
+ const phpType = idType === 'Int' || idType === 'BigInt' || idType === 'TinyInt' ? 'int' : 'string';
46
+ return nullable ? `${phpType}|null` : phpType;
47
+ }
5
48
  /** Map Omnify property type to PHP cast. */
6
49
  export function toCast(type, property = {}) {
7
50
  switch (type) {
@@ -56,7 +99,7 @@ export function toPhpDocType(type, nullable = false) {
56
99
  phpType = 'mixed';
57
100
  break;
58
101
  case 'Json':
59
- phpType = 'array';
102
+ phpType = 'array<array-key, mixed>';
60
103
  break;
61
104
  default:
62
105
  phpType = 'mixed';
@@ -85,11 +128,24 @@ export function toStoreRules(property, tableName, fkRuleType = 'integer') {
85
128
  const unique = property['unique'] ?? false;
86
129
  const vr = property['rules'];
87
130
  const rules = [];
88
- // required/nullable — rules.required can override
89
- const isRequired = vr?.['required'] !== undefined
90
- ? vr['required']
131
+ // required/nullable/sometimes — rules.required can override; non-nullable + default → sometimes (#235)
132
+ const explicitRequired = vr?.['required'];
133
+ const hasExplicitRequired = explicitRequired !== undefined;
134
+ const isRequired = hasExplicitRequired
135
+ ? explicitRequired
91
136
  : !nullable;
92
- rules.push(isRequired ? 'required' : 'nullable');
137
+ // `default: null` on a non-nullable column is contradictory YAML — treat it
138
+ // as no default so the column stays 'required' rather than silently optional.
139
+ const hasDefault = property['default'] !== undefined && property['default'] !== null;
140
+ if (!isRequired) {
141
+ rules.push('nullable');
142
+ }
143
+ else if (!hasExplicitRequired && !nullable && hasDefault) {
144
+ rules.push('sometimes');
145
+ }
146
+ else {
147
+ rules.push('required');
148
+ }
93
149
  switch (type) {
94
150
  case 'String':
95
151
  case 'Slug':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "6.7.13",
3
+ "version": "6.7.15",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",