@omnifyjp/ts 7.2.1 → 7.3.1

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.
@@ -80,9 +80,7 @@ function generateFileResourceUser(config) {
80
80
  */
81
81
 
82
82
  namespace ${resourceNamespace};
83
-
84
- use ${baseNamespace}\\FileResourceBase;
85
-
83
+ ${baseNamespace === resourceNamespace ? '' : `\nuse ${baseNamespace}\\FileResourceBase;\n`}
86
84
  /**
87
85
  * FileResource — add project-specific serialization here.
88
86
  */
@@ -190,6 +190,7 @@ function generateBaseModel(name, schema, reader, config) {
190
190
  * The database connection for the model.
191
191
  */
192
192
  protected $connection = '${connectionName}';
193
+
193
194
  `
194
195
  : '';
195
196
  const isAuthenticatable = options['authenticatable'] ?? false;
@@ -312,8 +313,7 @@ namespace ${baseNamespace};
312
313
  ${imports}
313
314
  /**
314
315
  * ${modelName}BaseModel
315
- *
316
- ${docProperties} */
316
+ ${docProperties ? ` *\n${docProperties}` : ''} */
317
317
  class ${modelName}BaseModel extends ${baseClass}${implementsClause}
318
318
  {
319
319
  ${traits}${connectionSection} /**
@@ -1071,6 +1071,7 @@ function buildInverseRelations(parentName, modelNamespace, reader, declaredMetho
1071
1071
  methods.push(`
1072
1072
  /**
1073
1073
  * Auto-generated inverse of ${childName}.${childPropName} (${relation}).
1074
+ *
1074
1075
  * @return ${returnType}<${fqcn}, $this>
1075
1076
  */
1076
1077
  public function ${methodName}(): ${returnType}
@@ -427,7 +427,7 @@ export function buildControllerMethodAttributes(config, schema, modelName, schem
427
427
  parameters: [
428
428
  new OA\\Parameter(name: 'search', in: 'query', required: false, schema: new OA\\Schema(type: 'string')),
429
429
  new OA\\Parameter(name: 'sort', in: 'query', required: false, schema: new OA\\Schema(type: 'string')),
430
- new OA\\Parameter(name: 'per_page', in: 'query', required: false, schema: new OA\\Schema(type: 'integer', default: ${schema.options?.api?.perPage ?? 15})),
430
+ new OA\\Parameter(name: 'per_page', in: 'query', required: false, schema: new OA\\Schema(type: 'integer', default: ${schema.options?.api?.perPage ?? 15}, minimum: 1, maximum: ${schema.options?.api?.maxPerPage ?? 100})),
431
431
  new OA\\Parameter(name: 'page', in: 'query', required: false, schema: new OA\\Schema(type: 'integer')),
432
432
  ],
433
433
  responses: [
@@ -68,6 +68,7 @@ function generateBaseRequest(name, schema, reader, config, action) {
68
68
  const isUpdate = action === 'Update';
69
69
  const rulesLines = [];
70
70
  const attributeKeys = [];
71
+ const translatableRules = [];
71
72
  for (const propName of propertyOrder) {
72
73
  const prop = properties[propName];
73
74
  if (!prop)
@@ -183,19 +184,26 @@ function generateBaseRequest(name, schema, reader, config, action) {
183
184
  : formatRules(rules);
184
185
  rulesLines.push(` '${snakeName}' => ${formattedRules},`);
185
186
  attributeKeys.push(snakeName);
186
- // Translatable: add locale-nested rules (Astrotomic expects {locale}.{field})
187
+ // Translatable: collect locale-nested rules (Astrotomic expects {locale}.{field}).
188
+ // Emitted after the loop so each `<locale>` array key appears ONCE no
189
+ // matter how many translatable fields the schema has (#281: per-field
190
+ // emission produced duplicate array keys, PHPStan array.duplicateKey).
187
191
  const isTranslatable = prop['translatable'] ?? false;
188
192
  if (isTranslatable) {
189
- const locales = reader.getLocales();
190
193
  // Build nullable version of the same rules
191
194
  const localeRules = rules.map(r => {
192
195
  if (r === 'required')
193
196
  return 'nullable';
194
197
  return r;
195
198
  });
196
- for (const locale of locales) {
197
- rulesLines.push(` '${locale}' => ['nullable', 'array'],`);
198
- rulesLines.push(` '${locale}.${snakeName}' => ${formatRules(localeRules)},`);
199
+ translatableRules.push({ snakeName, rules: formatRules(localeRules) });
200
+ }
201
+ }
202
+ if (translatableRules.length > 0) {
203
+ for (const locale of reader.getLocales()) {
204
+ rulesLines.push(` '${locale}' => ['nullable', 'array'],`);
205
+ for (const t of translatableRules) {
206
+ rulesLines.push(` '${locale}.${t.snakeName}' => ${t.rules},`);
199
207
  }
200
208
  }
201
209
  }
@@ -6,6 +6,43 @@ import { resolveTimestamps } from './timestamps.js';
6
6
  import { isHiddenByDefault, toResourceExpression } from './type-mapper.js';
7
7
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
8
8
  import { createClassImporter, renderUseBlock } from './php-style.js';
9
+ /**
10
+ * Instance members that make `$this-><name>` inside a JsonResource NOT a plain
11
+ * read of the model's attribute/relation (#281):
12
+ * - JsonResource's own public properties — `$this->with` / `$this->additional`
13
+ * / `$this->resource` read the resource object itself, not the model.
14
+ * - Eloquent Model's protected properties (Model + HasAttributes,
15
+ * HasRelationships, HasEvents, HidesAttributes, GuardsAttributes). Runtime
16
+ * reaches the attribute through __get, but via `@mixin Model` PHPStan sees a
17
+ * protected property (`property.protected`) — e.g. a `relations` association
18
+ * produced `Resource::collection($this->relations)`.
19
+ * For these, emit `$this->resource-><name>`, which always goes through the
20
+ * model's __get. Model's PUBLIC properties (exists, timestamps, incrementing, …)
21
+ * are not listed: they shadow the attribute on the model itself, so no
22
+ * resource-side spelling can reach it.
23
+ */
24
+ const RESOURCE_RESERVED_MEMBERS = new Set([
25
+ // JsonResource
26
+ 'resource', 'with', 'additional',
27
+ // Illuminate\Database\Eloquent\Model (protected)
28
+ 'connection', 'table', 'primaryKey', 'keyType', 'withCount', 'perPage', 'escapeWhenCastingToString',
29
+ // Concerns\HasAttributes
30
+ 'attributes', 'original', 'changes', 'previous', 'casts', 'classCastCache', 'attributeCastCache', 'dateFormat', 'appends',
31
+ // Concerns\HasRelationships
32
+ 'relations', 'touches',
33
+ // Concerns\HasEvents
34
+ 'dispatchesEvents', 'observables',
35
+ // Concerns\HidesAttributes / GuardsAttributes
36
+ 'hidden', 'visible', 'fillable', 'guarded',
37
+ ]);
38
+ /** `$this-><name>` for a model attribute/relation, collision-safe (#281). */
39
+ function modelMember(name) {
40
+ return RESOURCE_RESERVED_MEMBERS.has(name) ? `$this->resource->${name}` : `$this->${name}`;
41
+ }
42
+ /** Rewrite a `$this-><name>…` expression from toResourceExpression() collision-safe (#281). */
43
+ function safeResourceExpression(name, expr) {
44
+ return RESOURCE_RESERVED_MEMBERS.has(name) ? expr.replace(`$this->${name}`, modelMember(name)) : expr;
45
+ }
9
46
  /** Generate Resource classes for all project-owned visible object schemas.
10
47
  *
11
48
  * Issue #101 v5.8.23 emitted ONLY the base — but child resources embed a
@@ -111,7 +148,7 @@ function generateBaseResource(name, schema, reader, config) {
111
148
  for (const col of expandedProperties[propName].columns ?? []) {
112
149
  const colName = col.name ?? '';
113
150
  const colType = col.type ?? 'String';
114
- const expr = toResourceExpression(colName, colType);
151
+ const expr = safeResourceExpression(colName, toResourceExpression(colName, colType));
115
152
  fields.push(` '${colName}' => ${expr},`);
116
153
  }
117
154
  const sourceType = expandedProperties[propName].sourceType ?? '';
@@ -128,7 +165,7 @@ function generateBaseResource(name, schema, reader, config) {
128
165
  continue;
129
166
  }
130
167
  const snakeName = toSnakeCase(propName);
131
- const expr = toResourceExpression(snakeName, type);
168
+ const expr = safeResourceExpression(snakeName, toResourceExpression(snakeName, type));
132
169
  fields.push(` '${snakeName}' => ${expr},`);
133
170
  }
134
171
  // Include translations for translatable models
@@ -331,7 +368,7 @@ function addAssociationFields(schemaName, propName, prop, fields, resourceNamesp
331
368
  }
332
369
  if (relation === 'ManyToOne') {
333
370
  const snakeName = toFkColumnName(propName);
334
- fields.push(` '${snakeName}' => $this->${snakeName},`);
371
+ fields.push(` '${snakeName}' => ${modelMember(snakeName)},`);
335
372
  }
336
373
  fields.push(` '${methodName}' => $this->whenLoaded('${methodName}'),`);
337
374
  return;
@@ -340,18 +377,18 @@ function addAssociationFields(schemaName, propName, prop, fields, resourceNamesp
340
377
  switch (relation) {
341
378
  case 'ManyToOne': {
342
379
  const snakeName = toFkColumnName(propName);
343
- fields.push(` '${snakeName}' => $this->${snakeName},`);
344
- fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn () => new ${importer.ref(`${targetResNs}\\${targetResource}`)}($this->${methodName})),`);
380
+ fields.push(` '${snakeName}' => ${modelMember(snakeName)},`);
381
+ fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn () => new ${importer.ref(`${targetResNs}\\${targetResource}`)}(${modelMember(methodName)})),`);
345
382
  break;
346
383
  }
347
384
  case 'OneToMany':
348
385
  case 'ManyToMany':
349
386
  case 'MorphMany':
350
- fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn () => ${importer.ref(`${targetResNs}\\${targetResource}`)}::collection($this->${methodName})),`);
387
+ fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn () => ${importer.ref(`${targetResNs}\\${targetResource}`)}::collection(${modelMember(methodName)})),`);
351
388
  break;
352
389
  case 'OneToOne':
353
390
  case 'MorphOne':
354
- fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn () => new ${importer.ref(`${targetResNs}\\${targetResource}`)}($this->${methodName})),`);
391
+ fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn () => new ${importer.ref(`${targetResNs}\\${targetResource}`)}(${modelMember(methodName)})),`);
355
392
  break;
356
393
  case 'MorphTo':
357
394
  fields.push(` '${methodName}' => $this->whenLoaded('${methodName}'),`);
@@ -58,6 +58,11 @@ export declare class SchemaReader {
58
58
  readonly defaultLocale: string;
59
59
  readonly fallbackLocale: string;
60
60
  readonly enforceLanguageFk?: boolean;
61
+ readonly translatable?: {
62
+ readonly toArrayAlwaysLoadsTranslations?: boolean;
63
+ readonly useFallback?: boolean;
64
+ readonly usePropertyFallback?: boolean;
65
+ };
61
66
  };
62
67
  getLocales(): string[];
63
68
  getDefaultLocale(): string;
@@ -473,7 +473,10 @@ function generateBaseService(name, schema, reader, config) {
473
473
  const svcRaw = options.service;
474
474
  const svc = (svcRaw && typeof svcRaw === 'object') ? svcRaw : {};
475
475
  const hasSoftDelete = options.softDelete ?? false;
476
- const perPage = api.perPage ?? 25;
476
+ // #280: per_page is clamped to [1, maxPerPage] inside list(). A default
477
+ // above the ceiling is pulled down to it.
478
+ const maxPerPage = api.maxPerPage ?? 100;
479
+ const perPage = Math.min(api.perPage ?? 25, maxPerPage);
477
480
  // #81: lookup fields come from property-level `lookupable: true`.
478
481
  // Legacy `options.service.lookupFields` still honoured (deprecation warning
479
482
  // fired earlier). Always emit lookup() — it's a free method on every
@@ -561,7 +564,7 @@ function generateBaseService(name, schema, reader, config) {
561
564
  // Allowed sort columns whitelist (#75.2: prevent SQL injection via sort filter)
562
565
  const allowedSortColumns = buildAllowedSortColumns(searchableFields, filterableFields, sortableFields, defaultSort, hasSoftDelete);
563
566
  // list() method
564
- sections.push(buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns, translationCtx, reader));
567
+ sections.push(buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, maxPerPage, allowedSortColumns, translationCtx, reader));
565
568
  // findById() method
566
569
  sections.push(buildFindByIdMethod(modelName, eagerLoad, eagerCount, hasSoftDelete));
567
570
  // ── Create section ────────────────────────────────────────────────────────
@@ -1086,7 +1089,7 @@ function buildAllowedSortColumns(searchableFields, filterableFields, sortableFie
1086
1089
  set.add(defaultCol);
1087
1090
  return Array.from(set).sort();
1088
1091
  }
1089
- function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns, tx, reader) {
1092
+ function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, maxPerPage, allowedSortColumns, tx, reader) {
1090
1093
  const lines = [];
1091
1094
  // #77 follow-up: strict multi-line array-shape enumerating every filter
1092
1095
  // key explicitly (no catch-all). Sort union includes -prefixed DESC variants.
@@ -1151,9 +1154,14 @@ ${listExample}
1151
1154
  // Sort
1152
1155
  lines.push('');
1153
1156
  lines.push(buildSortSection(sortableFields, defaultSort, allowedSortColumns, tx));
1154
- // Paginate
1157
+ // Paginate — #280: clamp both ends. A negative per_page would otherwise
1158
+ // disable LIMIT (whole-table dump) and a huge one is unbounded.
1155
1159
  lines.push(`
1156
- return $query->paginate($filters['per_page'] ?? ${perPage});
1160
+ $perPage = filter_var($filters['per_page'] ?? null, FILTER_VALIDATE_INT);
1161
+ $perPage = $perPage === false || $perPage === null ? ${perPage} : $perPage;
1162
+ $perPage = max(1, min($perPage, ${maxPerPage}));
1163
+
1164
+ return $query->paginate($perPage);
1157
1165
  }`);
1158
1166
  return lines.join('\n');
1159
1167
  }
@@ -20,4 +20,4 @@
20
20
  */
21
21
  import type { SchemaReader } from './schema-reader.js';
22
22
  import type { GeneratedFile, PhpConfig } from './types.js';
23
- export declare function generateTranslatableConfig(reader: SchemaReader, _config: PhpConfig): GeneratedFile[];
23
+ export declare function generateTranslatableConfig(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -19,7 +19,8 @@
19
19
  * or extend it via Laravel's config merging.
20
20
  */
21
21
  import { baseFile } from './types.js';
22
- export function generateTranslatableConfig(reader, _config) {
22
+ import { translationModelNamespace } from './translation-model-generator.js';
23
+ export function generateTranslatableConfig(reader, config) {
23
24
  // Only emit when at least one schema has translatable fields — otherwise
24
25
  // the file would just be dead weight in projects that don't use i18n.
25
26
  const objectSchemas = reader.getProjectObjectSchemas();
@@ -43,6 +44,14 @@ export function generateTranslatableConfig(reader, _config) {
43
44
  // `fallbackLocale` if set, otherwise fall back to the same defaultLocale.
44
45
  const fallbackLocale = reader.getLocale()?.fallbackLocale ?? defaultLocale;
45
46
  const localesPhp = locales.map(l => ` '${l}',`).join('\n');
47
+ // #281: options from omnify.yaml `locale.translatable.*`; absent = true
48
+ // (the values this file has always shipped with).
49
+ const opts = reader.getLocale()?.translatable ?? {};
50
+ const phpBool = (v) => ((v ?? true) ? 'true' : 'false');
51
+ // #281: must match where translation-model-generator writes the
52
+ // `{Model}Translation` classes — `null` made Astrotomic look next to the
53
+ // parent model (e.g. App\Models\IssueTranslation) and fail.
54
+ const translationNamespace = translationModelNamespace(config).replace(/\\/g, '\\\\');
46
55
  const content = `<?php
47
56
 
48
57
  /**
@@ -104,14 +113,14 @@ ${localesPhp}
104
113
  | which would surface as blank dropdown labels in multi-locale projects.
105
114
  |
106
115
  */
107
- 'use_fallback' => true,
116
+ 'use_fallback' => ${phpBool(opts.useFallback)},
108
117
 
109
118
  /*
110
119
  |--------------------------------------------------------------------------
111
120
  | Use property fallback
112
121
  |--------------------------------------------------------------------------
113
122
  */
114
- 'use_property_fallback' => true,
123
+ 'use_property_fallback' => ${phpBool(opts.usePropertyFallback)},
115
124
 
116
125
  /*
117
126
  |--------------------------------------------------------------------------
@@ -125,7 +134,7 @@ ${localesPhp}
125
134
  | Translation model namespace
126
135
  |--------------------------------------------------------------------------
127
136
  */
128
- 'translation_model_namespace' => null,
137
+ 'translation_model_namespace' => '${translationNamespace}',
129
138
 
130
139
  /*
131
140
  |--------------------------------------------------------------------------
@@ -150,7 +159,7 @@ ${localesPhp}
150
159
  | Keeps paginator results self-contained for JSON API responses.
151
160
  |
152
161
  */
153
- 'to_array_always_loads_translations' => true,
162
+ 'to_array_always_loads_translations' => ${phpBool(opts.toArrayAlwaysLoadsTranslations)},
154
163
 
155
164
  /*
156
165
  |--------------------------------------------------------------------------
@@ -11,3 +11,9 @@ import { SchemaReader } from './schema-reader.js';
11
11
  import type { GeneratedFile, PhpConfig } from './types.js';
12
12
  /** Generate translation model files for all project schemas with translatable fields. */
13
13
  export declare function generateTranslationModels(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
14
+ /**
15
+ * Namespace of the editable `{Model}Translation` classes. Also emitted as
16
+ * Astrotomic's `translation_model_namespace` in config/translatable.php so
17
+ * the trait resolves the classes this generator writes (#281).
18
+ */
19
+ export declare function translationModelNamespace(config: PhpConfig): string;
@@ -66,6 +66,7 @@ function generateTranslationBaseModel(name, translatableFields, enforceLanguageF
66
66
  * The database connection for the model.
67
67
  */
68
68
  protected $connection = '${connectionName}';
69
+
69
70
  `
70
71
  : '';
71
72
  // Issue #98 v5.8.2: consolidate every Translation model under one
@@ -161,6 +162,16 @@ ${fillableLines}
161
162
  : `${config.models.basePath}/Translation/${baseClassName}.php`;
162
163
  return baseFile(filePath, content);
163
164
  }
165
+ /**
166
+ * Namespace of the editable `{Model}Translation` classes. Also emitted as
167
+ * Astrotomic's `translation_model_namespace` in config/translatable.php so
168
+ * the trait resolves the classes this generator writes (#281).
169
+ */
170
+ export function translationModelNamespace(config) {
171
+ return config.structure === 'modular'
172
+ ? config.models.namespace
173
+ : `${config.models.userEditableNamespace}\\Translation`;
174
+ }
164
175
  function generateTranslationUserModel(name, config) {
165
176
  const modelName = toPascalCase(name);
166
177
  // Issue #98 v5.8.2: editable translation stub also lives under
@@ -171,9 +182,7 @@ function generateTranslationUserModel(name, config) {
171
182
  // the FILE LOCATION, not the base namespace — otherwise PSR-4 fails
172
183
  // at runtime. Use `userEditableNamespace` (resolved from the config
173
184
  // override) instead of the base `models.namespace`.
174
- const modelNamespace = config.structure === 'modular'
175
- ? config.models.namespace
176
- : `${config.models.userEditableNamespace}\\Translation`;
185
+ const modelNamespace = translationModelNamespace(config);
177
186
  const baseNamespace = config.structure === 'modular'
178
187
  ? resolveModularBaseNamespace(config, name, 'Models', config.models.baseNamespace)
179
188
  : `${config.models.baseNamespace}\\Translation`;
package/dist/types.d.ts CHANGED
@@ -62,6 +62,12 @@ export interface SchemasJson {
62
62
  readonly defaultLocale: string;
63
63
  readonly fallbackLocale: string;
64
64
  readonly enforceLanguageFk?: boolean;
65
+ /** `locale.translatable.*` from omnify.yaml — config/translatable.php options (#281). */
66
+ readonly translatable?: {
67
+ readonly toArrayAlwaysLoadsTranslations?: boolean;
68
+ readonly useFallback?: boolean;
69
+ readonly usePropertyFallback?: boolean;
70
+ };
65
71
  };
66
72
  readonly customTypes: {
67
73
  readonly compound: Record<string, CompoundTypeDefinition>;
@@ -142,6 +148,8 @@ export interface ApiOptions {
142
148
  readonly restore?: boolean;
143
149
  readonly middleware?: readonly string[];
144
150
  readonly perPage?: number;
151
+ /** Upper bound for `per_page` in the generated list() (#280). Default 100. */
152
+ readonly maxPerPage?: number;
145
153
  }
146
154
  /**
147
155
  * Service layer codegen options — controls BaseService generation. Issue #57.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "7.2.1",
3
+ "version": "7.3.1",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",