@omnifyjp/ts 6.7.14 → 6.7.16

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.
@@ -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';
@@ -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));
@@ -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,14 @@ 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 matches, fall back to the owning schema
35
+ * name (external targets). If two or more matches, throw — omnify-go#242.
36
+ * Issue #237.
37
+ */
38
+ export declare function resolveHasManyForeignKey(propName: string, target: string, context?: RelationContext): string;
27
39
  /**
28
40
  * Build a chained `->orderBy('col', 'dir')` PHP fragment from an OrderByList.
29
41
  * 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);
@@ -97,9 +97,11 @@ function oneToOne(propName, property, target, ns) {
97
97
  }
98
98
  return belongsTo(propName, property, target, ns);
99
99
  }
100
- function hasMany(propName, property, target, ns) {
100
+ function hasMany(propName, property, target, ns, context) {
101
101
  const inverse = property['mappedBy'] ?? property['inversedBy'] ?? null;
102
- const foreignKey = inverse ? toFkColumnName(inverse) : toFkColumnName(propName);
102
+ const foreignKey = inverse
103
+ ? toFkColumnName(inverse)
104
+ : resolveHasManyForeignKey(propName, target, context);
103
105
  const methodName = toCamelCase(propName);
104
106
  const fqcn = `\\${ns}\\${target}`;
105
107
  const orderChain = formatOrderByChain(property['orderBy']);
@@ -111,6 +113,44 @@ function hasMany(propName, property, target, ns) {
111
113
  return $this->hasMany(${fqcn}::class, '${foreignKey}')${orderChain};
112
114
  }`;
113
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 matches, fall back to the owning schema
120
+ * name (external targets). If two or more matches, throw — omnify-go#242.
121
+ * Issue #237.
122
+ */
123
+ export function resolveHasManyForeignKey(propName, target, context) {
124
+ const reader = context?.reader;
125
+ const owningSchemaName = context?.sourceSchemaName;
126
+ if (reader && owningSchemaName && target) {
127
+ const matches = findInverseManyToOneFkColumns(reader, target, owningSchemaName);
128
+ if (matches.length === 1) {
129
+ return matches[0];
130
+ }
131
+ if (matches.length >= 2) {
132
+ const columnList = matches.join(', ');
133
+ throw new Error(`${owningSchemaName}.${propName}: target ${target} has ${matches.length} ManyToOne associations back to ${owningSchemaName} (${columnList}) — set mappedBy: on the OneToMany to pick one`);
134
+ }
135
+ return toFkColumnName(owningSchemaName);
136
+ }
137
+ return toFkColumnName(propName);
138
+ }
139
+ function findInverseManyToOneFkColumns(reader, targetSchemaName, owningSchemaName) {
140
+ const targetSchema = reader.getSchema(targetSchemaName);
141
+ const props = (targetSchema?.properties ?? {});
142
+ const columns = [];
143
+ for (const [childPropName, childProp] of Object.entries(props)) {
144
+ if (childProp['type'] !== 'Association')
145
+ continue;
146
+ if (childProp['relation'] !== 'ManyToOne')
147
+ continue;
148
+ if (childProp['target'] !== owningSchemaName)
149
+ continue;
150
+ columns.push(childProp['column'] || toFkColumnName(childPropName));
151
+ }
152
+ return columns;
153
+ }
114
154
  /**
115
155
  * Build a chained `->orderBy('col', 'dir')` PHP fragment from an OrderByList.
116
156
  * 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);
@@ -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) {
@@ -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.14",
3
+ "version": "6.7.16",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",