@omnifyjp/omnify 0.3.7 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/omnify",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "Schema-driven code generation for Laravel, TypeScript, and SQL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,10 +36,10 @@
36
36
  "zod": "^3.24.0"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@omnifyjp/omnify-darwin-arm64": "0.3.7",
40
- "@omnifyjp/omnify-darwin-x64": "0.3.7",
41
- "@omnifyjp/omnify-linux-x64": "0.3.7",
42
- "@omnifyjp/omnify-linux-arm64": "0.3.7",
43
- "@omnifyjp/omnify-win32-x64": "0.3.7"
39
+ "@omnifyjp/omnify-darwin-arm64": "0.4.0",
40
+ "@omnifyjp/omnify-darwin-x64": "0.4.0",
41
+ "@omnifyjp/omnify-linux-x64": "0.4.0",
42
+ "@omnifyjp/omnify-linux-arm64": "0.4.0",
43
+ "@omnifyjp/omnify-win32-x64": "0.4.0"
44
44
  }
45
45
  }
@@ -40,8 +40,16 @@ const PK_TYPE_MAP = {
40
40
  Int: 'number',
41
41
  BigInt: 'number',
42
42
  Uuid: 'string',
43
+ Ulid: 'string',
43
44
  String: 'string',
44
45
  };
46
+ /** Extract IdType from the unified `id` field. Returns 'BigInt' as default. */
47
+ function getIdType(options) {
48
+ const id = options?.id;
49
+ if (typeof id === 'string')
50
+ return id;
51
+ return 'BigInt';
52
+ }
45
53
  /** Gets TypeScript type for a property. */
46
54
  export function getPropertyType(property, allSchemas) {
47
55
  if (property.type === 'File') {
@@ -168,7 +176,7 @@ export function propertyToTSProperties(propertyName, property, schema, allSchema
168
176
  // Determine FK type from target schema
169
177
  let fkType = 'number';
170
178
  const targetSchema = property.target ? allSchemas[property.target] : undefined;
171
- const effectiveIdType = targetSchema?.options?.idType ?? 'BigInt';
179
+ const effectiveIdType = getIdType(targetSchema?.options);
172
180
  if (effectiveIdType === 'Uuid' || effectiveIdType === 'String') {
173
181
  fkType = 'string';
174
182
  }
@@ -226,7 +234,7 @@ export function schemaToInterface(schema, allSchemas, options) {
226
234
  const allSchemaNames = new Set(Object.keys(allSchemas).filter(name => allSchemas[name]?.kind !== 'enum'));
227
235
  // ID property
228
236
  if (schema.options?.id !== false) {
229
- const pkType = (schema.options?.idType ?? 'BigInt');
237
+ const pkType = getIdType(schema.options);
230
238
  properties.push({
231
239
  name: 'id',
232
240
  type: PK_TYPE_MAP[pkType] ?? 'number',
@@ -42,9 +42,10 @@ function generateBaseModel(name, schema, reader, config) {
42
42
  const relations = buildRelations(properties, propertyOrder, modelNamespace);
43
43
  const accessors = buildAccessors(expandedProperties);
44
44
  const primaryKey = options['primaryKey'] ?? 'id';
45
- const idType = options.idType ?? 'BigInt';
45
+ const rawId = options.id;
46
+ const idType = typeof rawId === 'string' ? rawId : 'BigInt';
46
47
  let keyTypeSection = '';
47
- if (idType === 'Uuid' || idType === 'String') {
48
+ if (idType === 'Uuid' || idType === 'Ulid' || idType === 'String') {
48
49
  keyTypeSection = `
49
50
  /**
50
51
  * The type of the primary key.
@@ -63,13 +63,25 @@ export function toPhpDocType(type, nullable = false) {
63
63
  }
64
64
  return nullable ? `${phpType}|null` : phpType;
65
65
  }
66
+ /** Type categories for rule applicability. */
67
+ const STRING_TYPES = new Set([
68
+ 'String', 'Email', 'Password', 'Text', 'MediumText', 'LongText',
69
+ 'Slug', 'Url', 'Phone', 'Uuid',
70
+ ]);
71
+ const NUMERIC_TYPES = new Set(['Int', 'BigInt', 'TinyInt', 'Float', 'Decimal']);
72
+ const ARRAY_TYPES = new Set(['Json']);
66
73
  /** Generate validation rules for a property (Store request). */
67
74
  export function toStoreRules(property, tableName) {
68
75
  const type = property['type'] ?? 'String';
69
76
  const nullable = property['nullable'] ?? false;
70
77
  const unique = property['unique'] ?? false;
78
+ const vr = property['rules'];
71
79
  const rules = [];
72
- rules.push(nullable ? 'nullable' : 'required');
80
+ // required/nullable rules.required can override
81
+ const isRequired = vr?.['required'] !== undefined
82
+ ? vr['required']
83
+ : !nullable;
84
+ rules.push(isRequired ? 'required' : 'nullable');
73
85
  switch (type) {
74
86
  case 'String':
75
87
  case 'Slug':
@@ -143,8 +155,87 @@ export function toStoreRules(property, tableName) {
143
155
  if (unique && type !== 'Email') {
144
156
  rules.push(`unique:${tableName}`);
145
157
  }
158
+ // Merge explicit validation rules (additive/override)
159
+ if (vr) {
160
+ mergeValidationRules(rules, vr, type);
161
+ }
146
162
  return rules;
147
163
  }
164
+ /** Merge explicit ValidationRules into the inferred rules array. */
165
+ function mergeValidationRules(rules, vr, type) {
166
+ const isString = STRING_TYPES.has(type);
167
+ const isNumeric = NUMERIC_TYPES.has(type);
168
+ const isArray = ARRAY_TYPES.has(type);
169
+ if (isString) {
170
+ if (vr['minLength'] != null)
171
+ rules.push(`min:${vr['minLength']}`);
172
+ if (vr['maxLength'] != null) {
173
+ const idx = rules.findIndex(r => typeof r === 'string' && r.startsWith('max:'));
174
+ if (idx !== -1)
175
+ rules[idx] = `max:${vr['maxLength']}`;
176
+ else
177
+ rules.push(`max:${vr['maxLength']}`);
178
+ }
179
+ if (vr['url'] === true)
180
+ rules.push('url');
181
+ if (vr['uuid'] === true)
182
+ rules.push('uuid');
183
+ if (vr['ip'] === true)
184
+ rules.push('ip');
185
+ if (vr['ipv4'] === true)
186
+ rules.push('ipv4');
187
+ if (vr['ipv6'] === true)
188
+ rules.push('ipv6');
189
+ if (vr['alpha'] === true)
190
+ rules.push('alpha');
191
+ if (vr['alphaNum'] === true)
192
+ rules.push('alpha_num');
193
+ if (vr['alphaDash'] === true)
194
+ rules.push('alpha_dash');
195
+ if (vr['numeric'] === true)
196
+ rules.push('numeric');
197
+ if (vr['lowercase'] === true)
198
+ rules.push('lowercase');
199
+ if (vr['uppercase'] === true)
200
+ rules.push('uppercase');
201
+ if (vr['digits'] != null)
202
+ rules.push(`digits:${vr['digits']}`);
203
+ if (vr['digitsBetween'] != null) {
204
+ const [a, b] = vr['digitsBetween'];
205
+ rules.push(`digits_between:${a},${b}`);
206
+ }
207
+ if (vr['startsWith'] != null) {
208
+ const v = vr['startsWith'];
209
+ rules.push(`starts_with:${Array.isArray(v) ? v.join(',') : v}`);
210
+ }
211
+ if (vr['endsWith'] != null) {
212
+ const v = vr['endsWith'];
213
+ rules.push(`ends_with:${Array.isArray(v) ? v.join(',') : v}`);
214
+ }
215
+ }
216
+ if (isNumeric) {
217
+ if (vr['min'] != null)
218
+ rules.push(`min:${vr['min']}`);
219
+ if (vr['max'] != null)
220
+ rules.push(`max:${vr['max']}`);
221
+ if (vr['between'] != null) {
222
+ const [a, b] = vr['between'];
223
+ rules.push(`between:${a},${b}`);
224
+ }
225
+ if (vr['gt'] != null)
226
+ rules.push(`gt:${vr['gt']}`);
227
+ if (vr['lt'] != null)
228
+ rules.push(`lt:${vr['lt']}`);
229
+ if (vr['multipleOf'] != null)
230
+ rules.push(`multiple_of:${vr['multipleOf']}`);
231
+ }
232
+ if (isArray) {
233
+ if (vr['arrayMin'] != null)
234
+ rules.push(`min:${vr['arrayMin']}`);
235
+ if (vr['arrayMax'] != null)
236
+ rules.push(`max:${vr['arrayMax']}`);
237
+ }
238
+ }
148
239
  /** Generate validation rules for a property (Update request). */
149
240
  export function toUpdateRules(property, tableName, modelRouteParam) {
150
241
  let rules = toStoreRules(property, tableName);
@@ -57,8 +57,7 @@ export interface ExpandedColumn {
57
57
  }
58
58
  /** Schema options. */
59
59
  export interface SchemaOptions {
60
- readonly id?: boolean;
61
- readonly idType?: string;
60
+ readonly id?: boolean | string;
62
61
  readonly timestamps?: boolean;
63
62
  readonly softDelete?: boolean;
64
63
  readonly hidden?: boolean;
@@ -81,6 +80,35 @@ export interface SchemaDefinition {
81
80
  readonly values?: readonly EnumValueDefinition[];
82
81
  readonly pivotFor?: readonly string[];
83
82
  }
83
+ /** Application-level validation rules. Single source of truth for both Laravel validation and Zod schemas. */
84
+ export interface ValidationRules {
85
+ readonly required?: boolean;
86
+ readonly minLength?: number;
87
+ readonly maxLength?: number;
88
+ readonly url?: boolean;
89
+ readonly uuid?: boolean;
90
+ readonly ip?: boolean;
91
+ readonly ipv4?: boolean;
92
+ readonly ipv6?: boolean;
93
+ readonly alpha?: boolean;
94
+ readonly alphaNum?: boolean;
95
+ readonly alphaDash?: boolean;
96
+ readonly numeric?: boolean;
97
+ readonly digits?: number;
98
+ readonly digitsBetween?: readonly [number, number];
99
+ readonly startsWith?: string | readonly string[];
100
+ readonly endsWith?: string | readonly string[];
101
+ readonly lowercase?: boolean;
102
+ readonly uppercase?: boolean;
103
+ readonly min?: number;
104
+ readonly max?: number;
105
+ readonly between?: readonly [number, number];
106
+ readonly gt?: number;
107
+ readonly lt?: number;
108
+ readonly multipleOf?: number;
109
+ readonly arrayMin?: number;
110
+ readonly arrayMax?: number;
111
+ }
84
112
  /** Property definition within a schema. */
85
113
  export interface PropertyDefinition {
86
114
  readonly type: string;
@@ -100,6 +128,7 @@ export interface PropertyDefinition {
100
128
  readonly precision?: number;
101
129
  readonly scale?: number;
102
130
  readonly pattern?: string;
131
+ readonly rules?: ValidationRules;
103
132
  readonly enum?: string | readonly string[];
104
133
  readonly relation?: string;
105
134
  readonly target?: string;
@@ -26,11 +26,112 @@ function getMultiLocaleDisplayName(value, locales, fallbackLocale, defaultValue)
26
26
  }
27
27
  return result;
28
28
  }
29
+ /** Type categories for Zod rule applicability. */
30
+ const ZOD_STRING_TYPES = new Set([
31
+ 'String', 'Email', 'Password', 'Text', 'MediumText', 'LongText',
32
+ 'Slug', 'Url', 'Phone', 'Uuid',
33
+ ]);
34
+ const ZOD_NUMERIC_TYPES = new Set(['Int', 'BigInt', 'TinyInt', 'Float', 'Decimal']);
35
+ /** Replace an existing Zod method call or append it. */
36
+ function replaceZodMethod(schema, methodName, replacement) {
37
+ const regex = new RegExp(`\\.${methodName}\\([^)]*\\)`);
38
+ if (regex.test(schema)) {
39
+ return schema.replace(regex, replacement);
40
+ }
41
+ return schema + replacement;
42
+ }
43
+ /** Apply validation rules to a Zod schema string (additive/override). */
44
+ function applyZodRules(schema, rules, type) {
45
+ const isString = ZOD_STRING_TYPES.has(type);
46
+ const isNumeric = ZOD_NUMERIC_TYPES.has(type);
47
+ if (isString) {
48
+ if (rules.minLength != null) {
49
+ schema = replaceZodMethod(schema, 'min', `.min(${rules.minLength})`);
50
+ }
51
+ if (rules.maxLength != null) {
52
+ schema = replaceZodMethod(schema, 'max', `.max(${rules.maxLength})`);
53
+ }
54
+ if (rules.url)
55
+ schema += '.url()';
56
+ if (rules.uuid)
57
+ schema += '.uuid()';
58
+ if (rules.ip)
59
+ schema += '.ip()';
60
+ if (rules.ipv4)
61
+ schema += `.ip({ version: "v4" })`;
62
+ if (rules.ipv6)
63
+ schema += `.ip({ version: "v6" })`;
64
+ if (rules.alpha)
65
+ schema += `.regex(/^[a-zA-Z]+$/)`;
66
+ if (rules.alphaNum)
67
+ schema += `.regex(/^[a-zA-Z0-9]+$/)`;
68
+ if (rules.alphaDash)
69
+ schema += `.regex(/^[a-zA-Z0-9_-]+$/)`;
70
+ if (rules.numeric)
71
+ schema += `.regex(/^[0-9]+$/)`;
72
+ if (rules.lowercase)
73
+ schema += `.regex(/^[^A-Z]*$/)`;
74
+ if (rules.uppercase)
75
+ schema += `.regex(/^[^a-z]*$/)`;
76
+ if (rules.digits != null) {
77
+ schema += `.length(${rules.digits}).regex(/^[0-9]+$/)`;
78
+ }
79
+ if (rules.digitsBetween != null) {
80
+ const [a, b] = rules.digitsBetween;
81
+ schema = replaceZodMethod(schema, 'min', `.min(${a})`);
82
+ schema = replaceZodMethod(schema, 'max', `.max(${b})`);
83
+ schema += `.regex(/^[0-9]+$/)`;
84
+ }
85
+ if (rules.startsWith != null) {
86
+ const v = rules.startsWith;
87
+ if (Array.isArray(v)) {
88
+ const escaped = v.map(s => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'));
89
+ schema += `.regex(/^(${escaped.join('|')})/)`;
90
+ }
91
+ else {
92
+ schema += `.startsWith('${v}')`;
93
+ }
94
+ }
95
+ if (rules.endsWith != null) {
96
+ const v = rules.endsWith;
97
+ if (Array.isArray(v)) {
98
+ const escaped = v.map(s => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'));
99
+ schema += `.regex(/(${escaped.join('|')})$/)`;
100
+ }
101
+ else {
102
+ schema += `.endsWith('${v}')`;
103
+ }
104
+ }
105
+ }
106
+ if (isNumeric) {
107
+ if (rules.min != null) {
108
+ schema = replaceZodMethod(schema, 'gte', `.gte(${rules.min})`);
109
+ }
110
+ if (rules.max != null) {
111
+ schema = replaceZodMethod(schema, 'lte', `.lte(${rules.max})`);
112
+ }
113
+ if (rules.between != null) {
114
+ const [a, b] = rules.between;
115
+ schema = replaceZodMethod(schema, 'gte', `.gte(${a})`);
116
+ schema = replaceZodMethod(schema, 'lte', `.lte(${b})`);
117
+ }
118
+ if (rules.gt != null)
119
+ schema += `.gt(${rules.gt})`;
120
+ if (rules.lt != null)
121
+ schema += `.lt(${rules.lt})`;
122
+ if (rules.multipleOf != null)
123
+ schema += `.multipleOf(${rules.multipleOf})`;
124
+ }
125
+ return schema;
126
+ }
29
127
  /**
30
128
  * Generate Zod schema string for a property type.
31
129
  */
32
130
  function getZodSchemaForType(propDef, _fieldName, options) {
33
- const isNullable = propDef.nullable ?? false;
131
+ // rules.required overrides nullable inference
132
+ const isNullable = propDef.rules?.required !== undefined
133
+ ? !propDef.rules.required
134
+ : (propDef.nullable ?? false);
34
135
  let schema = '';
35
136
  // Check for simple custom types
36
137
  const simpleType = options.customTypes.simple[propDef.type];
@@ -39,6 +140,9 @@ function getZodSchemaForType(propDef, _fieldName, options) {
39
140
  if (simpleType.length) {
40
141
  schema += `.max(${simpleType.length})`;
41
142
  }
143
+ if (propDef.rules) {
144
+ schema = applyZodRules(schema, propDef.rules, propDef.type);
145
+ }
42
146
  if (isNullable) {
43
147
  schema += '.optional().nullable()';
44
148
  }
@@ -128,12 +232,17 @@ function getZodSchemaForType(propDef, _fieldName, options) {
128
232
  default:
129
233
  schema = 'z.string()';
130
234
  }
131
- if (isNullable && schema) {
132
- schema += '.optional().nullable()';
235
+ // Apply validation rules (additive/override)
236
+ if (propDef.rules && schema) {
237
+ schema = applyZodRules(schema, propDef.rules, propDef.type);
133
238
  }
239
+ // Pattern (before nullable so validators chain correctly)
134
240
  if (propDef.pattern && schema) {
135
241
  schema += `.regex(/${propDef.pattern}/)`;
136
242
  }
243
+ if (isNullable && schema) {
244
+ schema += '.optional().nullable()';
245
+ }
137
246
  return schema;
138
247
  }
139
248
  /**
package/types/schema.d.ts CHANGED
@@ -55,7 +55,7 @@ export type ReferentialAction =
55
55
  export type SchemaKind = "object" | "enum" | "partial" | "extend" | "pivot";
56
56
 
57
57
  /** Primary key column type. */
58
- export type IdType = "BigInt" | "Int" | "Uuid" | "String";
58
+ export type IdType = "BigInt" | "Int" | "Uuid" | "Ulid" | "String";
59
59
 
60
60
  /** Database index type. */
61
61
  export type IndexType = "btree" | "hash" | "fulltext" | "spatial" | "gin" | "gist";
@@ -97,36 +97,85 @@ export interface PivotFieldDefinition {
97
97
  displayName?: LocalizedString;
98
98
  }
99
99
 
100
- /** Application-level validation rules. */
100
+ /**
101
+ * Application-level validation rules. Single source of truth for both Laravel validation and Zod schemas.
102
+ * Rules are **additive** — generators infer base rules from type, `rules:` adds/overrides on top.
103
+ *
104
+ * **Mutually exclusive groups** (using more than one triggers a validation error):
105
+ * - `lowercase` + `uppercase`
106
+ * - `alpha` / `alphaNum` / `alphaDash` / `numeric` (pick one)
107
+ * - `url` + `uuid`
108
+ * - `min` + `gt` (both define lower bound)
109
+ * - `max` + `lt` (both define upper bound)
110
+ * - `between` + any of `min`/`max`/`gt`/`lt`
111
+ * - `digits` + `digitsBetween`
112
+ *
113
+ * **Redundancy warnings**:
114
+ * - `ip` + `ipv4` or `ip` + `ipv6` → ipv4/ipv6 is redundant
115
+ *
116
+ * **Cross-field**: `maxLength` vs property `length` — if both set and differ, omnify warns.
117
+ */
101
118
  export interface ValidationRules {
119
+ /** [All types] Override required/nullable inference. */
102
120
  required?: boolean;
103
- // String rules
121
+
122
+ // ── String rules (apply to: String, Email, Password, Text, MediumText, LongText, Uuid) ──
123
+
124
+ /** Minimum string length. Must be >= 0. Must be <= maxLength when both set. */
104
125
  minLength?: number;
126
+ /** Maximum string length. Must be >= 1. Overrides length-based max. */
105
127
  maxLength?: number;
128
+ /** Must be valid URL. Mutually exclusive with uuid. */
106
129
  url?: boolean;
130
+ /** Must be valid UUID. Mutually exclusive with url. */
107
131
  uuid?: boolean;
132
+ /** Must be valid IP (v4 or v6). Makes ipv4/ipv6 redundant. */
108
133
  ip?: boolean;
134
+ /** Must be valid IPv4. Redundant when ip is set. */
109
135
  ipv4?: boolean;
136
+ /** Must be valid IPv6. Redundant when ip is set. */
110
137
  ipv6?: boolean;
138
+ /** Only letters (a-z, A-Z). Mutually exclusive with alphaNum, alphaDash, numeric. */
111
139
  alpha?: boolean;
140
+ /** Only letters and numbers. Mutually exclusive with alpha, alphaDash, numeric. */
112
141
  alphaNum?: boolean;
142
+ /** Letters, numbers, dash, underscore. Mutually exclusive with alpha, alphaNum, numeric. */
113
143
  alphaDash?: boolean;
144
+ /** Only digits (0-9) as string. Mutually exclusive with alpha, alphaNum, alphaDash. */
114
145
  numeric?: boolean;
146
+ /** Exactly N digits. Must be >= 1. Mutually exclusive with digitsBetween. */
115
147
  digits?: number;
148
+ /** Digit count between [min, max]. Both >= 1, min <= max. Mutually exclusive with digits. */
116
149
  digitsBetween?: [number, number];
150
+ /** Must start with prefix(es). */
117
151
  startsWith?: string | string[];
152
+ /** Must end with suffix(es). */
118
153
  endsWith?: string | string[];
154
+ /** Must be entirely lowercase. Mutually exclusive with uppercase. */
119
155
  lowercase?: boolean;
156
+ /** Must be entirely uppercase. Mutually exclusive with lowercase. */
120
157
  uppercase?: boolean;
121
- // Numeric rules
158
+
159
+ // ── Numeric rules (apply to: TinyInt, Int, BigInt, Float, Decimal) ──
160
+
161
+ /** Minimum value (inclusive). Must be <= max. Mutually exclusive with gt. Redundant with between. */
122
162
  min?: number;
163
+ /** Maximum value (inclusive). Must be >= min. Mutually exclusive with lt. Redundant with between. */
123
164
  max?: number;
165
+ /** Value between [min, max] inclusive. min <= max. Do not combine with min/max/gt/lt. */
124
166
  between?: [number, number];
167
+ /** Greater than (exclusive). Must be < lt. Mutually exclusive with min. Redundant with between. */
125
168
  gt?: number;
169
+ /** Less than (exclusive). Must be > gt. Mutually exclusive with max. Redundant with between. */
126
170
  lt?: number;
171
+ /** Must be a multiple of value. Must be > 0. */
127
172
  multipleOf?: number;
128
- // Array rules
173
+
174
+ // ── Array rules (apply to: Json) ──
175
+
176
+ /** Minimum items. Must be >= 0. Must be <= arrayMax when both set. */
129
177
  arrayMin?: number;
178
+ /** Maximum items. Must be >= 1. Must be >= arrayMin when both set. */
130
179
  arrayMax?: number;
131
180
  }
132
181
 
@@ -194,9 +243,8 @@ export interface IndexDefinition {
194
243
 
195
244
  /** Schema-level configuration options. */
196
245
  export interface SchemaOptions {
197
- /** Whether to auto-generate an 'id' primary key column. */
198
- id?: boolean;
199
- idType?: IdType;
246
+ /** ID column: true (BigInt), false (no ID), or type string (BigInt, Int, Uuid, String). */
247
+ id?: boolean | IdType;
200
248
  /** Custom primary key — single column or composite. */
201
249
  primaryKey?: string | string[];
202
250
  timestamps?: boolean;
@@ -204,7 +252,6 @@ export interface SchemaOptions {
204
252
  /** Unique constraints — column list or array of column lists. */
205
253
  unique?: string[] | string[][];
206
254
  indexes?: IndexDefinition[];
207
- translations?: boolean;
208
255
  tableName?: string;
209
256
  authenticatable?: boolean;
210
257
  hidden?: boolean;