@amritk/generate-parsers 0.2.1 → 0.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.
Files changed (38) hide show
  1. package/dist/generators/build-schema.d.ts +5 -1
  2. package/dist/generators/generate-files.d.ts +15 -2
  3. package/dist/helpers/collect-helpers.d.ts +16 -2
  4. package/dist/index.js +64 -19
  5. package/package.json +3 -2
  6. package/src/generators/build-schema.test.ts +830 -0
  7. package/src/generators/build-schema.ts +280 -0
  8. package/src/generators/generate-default-from-pattern.test.ts +81 -0
  9. package/src/generators/generate-default-from-pattern.ts +39 -0
  10. package/src/generators/generate-discriminated-union-validation.test.ts +84 -0
  11. package/src/generators/generate-discriminated-union-validation.ts +54 -0
  12. package/src/generators/generate-enum-check.test.ts +45 -0
  13. package/src/generators/generate-enum-check.ts +8 -0
  14. package/src/generators/generate-files.test.ts +435 -0
  15. package/src/generators/generate-files.ts +136 -0
  16. package/src/generators/generate-non-discriminated-union-validation.test.ts +74 -0
  17. package/src/generators/generate-non-discriminated-union-validation.ts +40 -0
  18. package/src/generators/generate-parser-function.test.ts +1798 -0
  19. package/src/generators/generate-parser-function.ts +1319 -0
  20. package/src/generators/generate-schema-checks.test.ts +322 -0
  21. package/src/generators/generate-schema-checks.ts +114 -0
  22. package/src/generators/generate-strict-assertion.ts +210 -0
  23. package/src/generators/generate-validation-expression.test.ts +794 -0
  24. package/src/generators/generate-validation-expression.ts +286 -0
  25. package/src/helpers/apply-schema-extensions.test.ts +194 -0
  26. package/src/helpers/apply-schema-extensions.ts +63 -0
  27. package/src/helpers/collect-helpers.test.ts +103 -0
  28. package/src/helpers/collect-helpers.ts +64 -0
  29. package/src/helpers/collect-imports.test.ts +587 -0
  30. package/src/helpers/collect-imports.ts +268 -0
  31. package/src/helpers/find-discriminator.test.ts +88 -0
  32. package/src/helpers/find-discriminator.ts +39 -0
  33. package/src/helpers/get-default-value.test.ts +135 -0
  34. package/src/helpers/get-default-value.ts +89 -0
  35. package/src/helpers/get-discriminator-value.test.ts +83 -0
  36. package/src/helpers/get-discriminator-value.ts +21 -0
  37. package/src/index.ts +1 -0
  38. package/src/types/schema-extensions.ts +35 -0
@@ -0,0 +1,435 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { generateFile } from './generate-files'
4
+
5
+ describe('generate-files', () => {
6
+ it('generates combined import for $ref property', () => {
7
+ const schema = {
8
+ type: 'object',
9
+ properties: {
10
+ contact: { $ref: '#/$defs/contact' },
11
+ },
12
+ }
13
+
14
+ const { content: result } = generateFile(schema, 'Document')
15
+
16
+ expect(result).toContain(
17
+ "import { type ContactObject, parseContactObject, validateContactObjectShape } from './contact';",
18
+ )
19
+ })
20
+
21
+ it('calls imported parser for optional $ref property', () => {
22
+ const schema = {
23
+ type: 'object',
24
+ properties: {
25
+ contact: { $ref: '#/$defs/contact' },
26
+ },
27
+ }
28
+
29
+ const { content: result } = generateFile(schema, 'Document')
30
+
31
+ // With local variable caching, optional $ref properties use the cached variable
32
+ expect(result).toContain('_contact !== undefined && { contact: parseContactObject(_contact) }')
33
+ })
34
+
35
+ it('calls imported parser for required $ref property', () => {
36
+ const schema = {
37
+ type: 'object',
38
+ properties: {
39
+ contact: { $ref: '#/$defs/contact' },
40
+ },
41
+ required: ['contact'],
42
+ }
43
+
44
+ const { content: result } = generateFile(schema, 'Document')
45
+
46
+ // With local variable caching, required $ref properties use the cached variable
47
+ expect(result).toContain('contact: parseContactObject(_contact),')
48
+ })
49
+
50
+ it('generates combined imports for multiple $ref properties', () => {
51
+ const schema = {
52
+ type: 'object',
53
+ properties: {
54
+ contact: { $ref: '#/$defs/contact' },
55
+ server: { $ref: '#/$defs/server' },
56
+ },
57
+ }
58
+
59
+ const { content: result } = generateFile(schema, 'Document')
60
+
61
+ expect(result).toContain(
62
+ "import { type ContactObject, parseContactObject, validateContactObjectShape } from './contact';",
63
+ )
64
+ expect(result).toContain(
65
+ "import { type ServerObject, parseServerObject, validateServerObjectShape } from './server';",
66
+ )
67
+ })
68
+
69
+ it('generates correct parser name for multi-word $ref', () => {
70
+ const schema = {
71
+ type: 'object',
72
+ properties: {
73
+ externalDoc: { $ref: '#/$defs/external-documentation' },
74
+ },
75
+ }
76
+
77
+ const { content: result } = generateFile(schema, 'Document')
78
+
79
+ expect(result).toContain(
80
+ "import { type ExternalDocumentationObject, parseExternalDocumentationObject, validateExternalDocumentationObjectShape } from './external-documentation';",
81
+ )
82
+ expect(result).toContain('parseExternalDocumentationObject(_externalDoc)')
83
+ })
84
+
85
+ it('does not generate ref imports when no $ref properties exist', () => {
86
+ const schema = {
87
+ type: 'object',
88
+ properties: {
89
+ name: { type: 'string' },
90
+ age: { type: 'number' },
91
+ },
92
+ }
93
+
94
+ const { content: result } = generateFile(schema, 'Simple')
95
+
96
+ // Should still import isObject helper
97
+ expect(result).toContain("import { isObject } from '@amritk/helpers/is-object';")
98
+ // But should not import any ref types (check for type imports specifically)
99
+ expect(result).not.toContain('import { type')
100
+ })
101
+
102
+ it('still validates non-ref properties inline', () => {
103
+ const schema = {
104
+ type: 'object',
105
+ properties: {
106
+ name: { type: 'string' },
107
+ contact: { $ref: '#/$defs/contact' },
108
+ },
109
+ }
110
+
111
+ const { content: result } = generateFile(schema, 'Document')
112
+
113
+ // Non-ref property should have inline validation against the cached local
114
+ expect(result).toContain('typeof _name === "string"')
115
+ // Ref property should call imported parser using cached variable
116
+ expect(result).toContain('parseContactObject(_contact)')
117
+ })
118
+
119
+ it('generates a correct import for -or-reference style $ref names', () => {
120
+ // Without stripping, 'callbacks-or-reference' maps to the filename 'callbacks-or-reference'
121
+ const schema = {
122
+ type: 'object',
123
+ properties: {
124
+ callbacks: { $ref: '#/$defs/callbacks-or-reference' },
125
+ },
126
+ }
127
+
128
+ const { content: result } = generateFile(schema, 'Document')
129
+
130
+ expect(result).toContain(
131
+ "import { type CallbacksOrReferenceObject, parseCallbacksOrReferenceObject, validateCallbacksOrReferenceObjectShape } from './callbacks-or-reference';",
132
+ )
133
+ expect(result).toContain('parseCallbacksOrReferenceObject(_callbacks)')
134
+ })
135
+
136
+ it('generates arrow function parser', () => {
137
+ const schema = {
138
+ type: 'object',
139
+ properties: {
140
+ name: { type: 'string' },
141
+ },
142
+ }
143
+
144
+ const { content: result } = generateFile(schema, 'Simple')
145
+
146
+ expect(result).toContain('export const parseSimple = (input: unknown): Simple =>')
147
+ expect(result).not.toContain('function parseSimple')
148
+ })
149
+
150
+ it('maps array items through imported parser for optional array with $ref items', () => {
151
+ const schema = {
152
+ type: 'object',
153
+ properties: {
154
+ servers: {
155
+ type: 'array',
156
+ items: { $ref: '#/$defs/server' },
157
+ },
158
+ },
159
+ }
160
+
161
+ const { content: result } = generateFile(schema, 'Document')
162
+
163
+ expect(result).toContain(
164
+ "import { type ServerObject, parseServerObject, validateServerObjectShape } from './server';",
165
+ )
166
+ // With local variable caching, array validators use the cached variable
167
+ expect(result).toContain('validateArray(_servers, parseServerObject)')
168
+ })
169
+
170
+ it('maps array items through imported parser for required array with $ref items', () => {
171
+ const schema = {
172
+ type: 'object',
173
+ properties: {
174
+ servers: {
175
+ type: 'array',
176
+ items: { $ref: '#/$defs/server' },
177
+ },
178
+ },
179
+ required: ['servers'],
180
+ }
181
+
182
+ const { content: result } = generateFile(schema, 'Document')
183
+
184
+ // Check for required imports
185
+ expect(result).toContain(
186
+ "import { type ServerObject, parseServerObject, validateServerObjectShape } from './server';",
187
+ )
188
+ expect(result).toContain("import { validateArray } from '@amritk/helpers/validate-array';")
189
+ expect(result).toContain("import { isObject } from '@amritk/helpers/is-object';")
190
+
191
+ // Check for required array validation using cached variable
192
+ expect(result).toContain('servers: validateArray(_servers, parseServerObject),')
193
+
194
+ // Should not use spread operator for required fields
195
+ expect(result).not.toContain('...(_servers')
196
+ })
197
+
198
+ it('generates type and parser for schema with extension properties', () => {
199
+ const schema = {
200
+ type: 'object',
201
+ properties: {
202
+ name: { type: 'string' },
203
+ 'x-enabled': { type: 'boolean' },
204
+ 'x-internal': { type: 'boolean' },
205
+ },
206
+ required: ['name'],
207
+ }
208
+
209
+ const { content: result } = generateFile(schema, 'Parameter')
210
+
211
+ // Type should include extension properties as optional
212
+ expect(result).toContain("'x-enabled'?: boolean")
213
+ expect(result).toContain("'x-internal'?: boolean")
214
+ // Parser should validate extension properties
215
+ expect(result).toContain('x_enabled')
216
+ expect(result).toContain('x_internal')
217
+ })
218
+
219
+ it('generates type and parser for schema with complex extension property', () => {
220
+ const schema = {
221
+ type: 'object',
222
+ properties: {
223
+ summary: { type: 'string' },
224
+ 'x-codegen': {
225
+ type: 'object',
226
+ properties: {
227
+ methodName: { type: 'string' },
228
+ },
229
+ },
230
+ },
231
+ }
232
+
233
+ const { content: result } = generateFile(schema, 'Operation')
234
+
235
+ // Type should include the complex extension
236
+ expect(result).toContain("'x-codegen'")
237
+ expect(result).toContain('methodName')
238
+ })
239
+
240
+ it('omits parser function in types-only mode', () => {
241
+ const schema = {
242
+ type: 'object',
243
+ properties: {
244
+ name: { type: 'string' },
245
+ },
246
+ }
247
+
248
+ const { content: result } = generateFile(schema, 'Simple', { typesOnly: true })
249
+
250
+ expect(result).toContain('export type Simple')
251
+ expect(result).not.toContain('export const parseSimple')
252
+ })
253
+
254
+ it('uses type-only imports for $ref properties in types-only mode', () => {
255
+ const schema = {
256
+ type: 'object',
257
+ properties: {
258
+ contact: { $ref: '#/$defs/contact' },
259
+ },
260
+ }
261
+
262
+ const { content: result } = generateFile(schema, 'Document', { typesOnly: true })
263
+
264
+ expect(result).toContain("import type { ContactObject } from './contact';")
265
+ expect(result).not.toContain('parseContactObject')
266
+ })
267
+
268
+ it('does not include runtime helper imports in types-only mode', () => {
269
+ const schema = {
270
+ type: 'object',
271
+ properties: {
272
+ name: { type: 'string' },
273
+ },
274
+ }
275
+
276
+ const { content: result } = generateFile(schema, 'Simple', { typesOnly: true })
277
+
278
+ // No parser helpers should be imported since there is no parser function
279
+ expect(result).not.toContain('import { isObject }')
280
+ expect(result).not.toContain('import { validateArray }')
281
+ expect(result).not.toContain('import { validateRecord }')
282
+ })
283
+
284
+ it('still generates the type definition in types-only mode', () => {
285
+ const schema = {
286
+ type: 'object',
287
+ properties: {
288
+ name: { type: 'string' },
289
+ age: { type: 'number' },
290
+ },
291
+ required: ['name'],
292
+ }
293
+
294
+ const { content: result } = generateFile(schema, 'User', { typesOnly: true })
295
+
296
+ expect(result).toContain('export type User')
297
+ expect(result).toContain('name: string')
298
+ expect(result).toContain('age?: number')
299
+ })
300
+
301
+ it('generates type-only import for array items $ref in types-only mode', () => {
302
+ const schema = {
303
+ type: 'object',
304
+ properties: {
305
+ servers: {
306
+ type: 'array',
307
+ items: { $ref: '#/$defs/server' },
308
+ },
309
+ },
310
+ }
311
+
312
+ const { content: result } = generateFile(schema, 'Document', { typesOnly: true })
313
+
314
+ expect(result).toContain("import type { ServerObject } from './server';")
315
+ expect(result).not.toContain('parseServerObject')
316
+ expect(result).not.toContain('validateArray')
317
+ })
318
+
319
+ it('generates type-only import for additionalProperties $ref in types-only mode', () => {
320
+ const schema = {
321
+ type: 'object',
322
+ additionalProperties: { $ref: '#/$defs/path-item' },
323
+ }
324
+
325
+ const { content: result } = generateFile(schema, 'Paths', { typesOnly: true })
326
+
327
+ expect(result).toContain("import type { PathItemObject } from './path-item';")
328
+ expect(result).not.toContain('parsePathItemObject')
329
+ expect(result).not.toContain('validateRecord')
330
+ })
331
+
332
+ it('generates type-only imports for oneOf $ref variants in types-only mode', () => {
333
+ const schema = {
334
+ oneOf: [{ $ref: '#/$defs/cat' }, { $ref: '#/$defs/dog' }],
335
+ }
336
+
337
+ const { content: result } = generateFile(schema, 'Pet', { typesOnly: true })
338
+
339
+ expect(result).toContain("import type { CatObject } from './cat';")
340
+ expect(result).toContain("import type { DogObject } from './dog';")
341
+ expect(result).not.toContain('parseCatObject')
342
+ expect(result).not.toContain('parseDogObject')
343
+ })
344
+
345
+ it('generates type-only imports for anyOf $ref variants in types-only mode', () => {
346
+ const schema = {
347
+ type: 'object',
348
+ properties: {
349
+ value: {
350
+ anyOf: [{ $ref: '#/$defs/string-value' }, { $ref: '#/$defs/number-value' }],
351
+ },
352
+ },
353
+ }
354
+
355
+ const { content: result } = generateFile(schema, 'Wrapper', { typesOnly: true })
356
+
357
+ expect(result).toContain("import type { StringValueObject } from './string-value';")
358
+ expect(result).toContain("import type { NumberValueObject } from './number-value';")
359
+ expect(result).not.toContain('parseStringValueObject')
360
+ expect(result).not.toContain('parseNumberValueObject')
361
+ })
362
+
363
+ it('does not change default behavior when typesOnly is false', () => {
364
+ const schema = {
365
+ type: 'object',
366
+ properties: {
367
+ contact: { $ref: '#/$defs/contact' },
368
+ },
369
+ }
370
+
371
+ const { content: defaultResult } = generateFile(schema, 'Document')
372
+ const { content: explicitFalseResult } = generateFile(schema, 'Document', { typesOnly: false })
373
+
374
+ expect(defaultResult).toBe(explicitFalseResult)
375
+ expect(defaultResult).toContain(
376
+ "import { type ContactObject, parseContactObject, validateContactObjectShape } from './contact';",
377
+ )
378
+ expect(defaultResult).toContain('export const parseDocument')
379
+ })
380
+
381
+ it('does not generate a self-import for recursive schemas in types-only mode', () => {
382
+ // Mirrors the 3.2.0 encoding schema: EncodingObject has properties itemEncoding, prefixEncoding,
383
+ // and encoding that all $ref back to #/$defs/encoding. The generated encoding.ts must not
384
+ // import EncodingObject from itself.
385
+ const schema = {
386
+ type: 'object',
387
+ properties: {
388
+ itemEncoding: { $ref: '#/$defs/encoding' },
389
+ prefixEncoding: {
390
+ type: 'array',
391
+ items: { $ref: '#/$defs/encoding' },
392
+ },
393
+ encoding: {
394
+ type: 'object',
395
+ additionalProperties: { $ref: '#/$defs/encoding' },
396
+ },
397
+ headers: {
398
+ type: 'object',
399
+ additionalProperties: { $ref: '#/$defs/header-or-reference' },
400
+ },
401
+ },
402
+ }
403
+
404
+ const { content: result } = generateFile(schema, 'EncodingObject', {
405
+ typesOnly: true,
406
+ selfRef: '#/$defs/encoding',
407
+ })
408
+
409
+ expect(result).not.toContain("import type { EncodingObject } from './encoding';")
410
+ expect(result).toContain("import type { HeaderOrReferenceObject } from './header-or-reference';")
411
+ expect(result).toContain('export type EncodingObject')
412
+ })
413
+
414
+ it('does not generate a self-import for recursive schemas in full parser mode', () => {
415
+ const schema = {
416
+ type: 'object',
417
+ properties: {
418
+ itemEncoding: { $ref: '#/$defs/encoding' },
419
+ headers: {
420
+ type: 'object',
421
+ additionalProperties: { $ref: '#/$defs/header-or-reference' },
422
+ },
423
+ },
424
+ }
425
+
426
+ const { content: result } = generateFile(schema, 'EncodingObject', { selfRef: '#/$defs/encoding' })
427
+
428
+ expect(result).not.toContain(
429
+ "import { type EncodingObject, parseEncodingObject, validateEncodingObjectShape } from './encoding';",
430
+ )
431
+ expect(result).toContain(
432
+ "import { type HeaderOrReferenceObject, parseHeaderOrReferenceObject, validateHeaderOrReferenceObjectShape } from './header-or-reference';",
433
+ )
434
+ })
435
+ })
@@ -0,0 +1,136 @@
1
+ import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition'
2
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
3
+ import {
4
+ type CollectedHelpers,
5
+ collectHelpers,
6
+ type HelpersMode,
7
+ type RuntimeHelperName,
8
+ } from '#helpers/collect-helpers'
9
+ import { collectImports } from '#helpers/collect-imports'
10
+
11
+ import { generateParserFunction, generateShapeValidator } from './generate-parser-function'
12
+
13
+ /**
14
+ * Options for controlling what gets generated in a file.
15
+ */
16
+ type GenerateFileOptions = {
17
+ /**
18
+ * When true, only emit the TypeScript type definition and skip the parser function.
19
+ * Imports will be type-only as well, since there are no parsers to call.
20
+ */
21
+ readonly typesOnly?: boolean
22
+ /**
23
+ * The $ref path of the schema being generated (e.g. `#/$defs/encoding`).
24
+ * When provided, any $ref that resolves to the same filename is excluded from
25
+ * the import list, preventing a file from importing itself.
26
+ */
27
+ readonly selfRef?: string
28
+ /**
29
+ * The root schema document. When provided, URI refs that cannot be resolved
30
+ * within the root schema's $defs are excluded from the import list.
31
+ */
32
+ readonly rootSchema?: Record<string, unknown>
33
+ /**
34
+ * When true, the generated parser emits a console.warn for every input key
35
+ * that is not declared in the schema's properties.
36
+ */
37
+ readonly logWarnings?: boolean
38
+ /**
39
+ * When true, the generated parser throws on type/shape mismatches instead
40
+ * of coercing invalid input to default values.
41
+ */
42
+ readonly strict?: boolean
43
+ /**
44
+ * Controls how runtime helpers are referenced.
45
+ * - `'package'` (default): emit imports from `@amritk/helpers/...`.
46
+ * - `'embedded'`: emit imports from `./_helpers/...` so the helper sources can
47
+ * be shipped alongside the generated files.
48
+ */
49
+ readonly helpersMode?: HelpersMode
50
+ }
51
+
52
+ /** Result of generating a single parser file. */
53
+ export type GeneratedFileContent = {
54
+ readonly content: string
55
+ readonly usedHelpers: ReadonlySet<RuntimeHelperName>
56
+ }
57
+
58
+ /**
59
+ * Generates a complete TypeScript file from a JSON Schema.
60
+ *
61
+ * The generated file contains imports, type definition, and parser function.
62
+ * When typesOnly is true, only the type definition is emitted without any parser.
63
+ *
64
+ * @param schema - The JSON Schema to generate code from
65
+ * @param typeName - The name to use for the generated TypeScript type
66
+ * @param options - Optional settings to control what gets generated
67
+ * @returns The generated file content and the set of runtime helpers it references
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * const schema = {
72
+ * type: "object",
73
+ * properties: {
74
+ * name: { type: "string" },
75
+ * age: { type: "number" }
76
+ * },
77
+ * required: ["name"]
78
+ * };
79
+ *
80
+ * const result = generateFile(schema, "User");
81
+ * // result contains imports, type User, and parseUser function
82
+ *
83
+ * const typesOnly = generateFile(schema, "User", { typesOnly: true });
84
+ * // typesOnly contains only type-only imports and type User (no parser)
85
+ * ```
86
+ */
87
+ export const generateFile = (
88
+ schema: JSONSchema,
89
+ typeName: string,
90
+ options?: GenerateFileOptions,
91
+ ): GeneratedFileContent => {
92
+ const typesOnly = options?.typesOnly === true
93
+ const selfRef = options?.selfRef
94
+ const rootSchema = options?.rootSchema
95
+ const helpersMode: HelpersMode = options?.helpersMode ?? 'package'
96
+ const typeDefinition = generateTypeDefinition(schema, typeName)
97
+
98
+ if (typesOnly) {
99
+ // In types-only mode, skip the parser function and use type-only imports
100
+ const imports = collectImports(schema, { typesOnly: true, selfRef, rootSchema })
101
+ let result = ''
102
+
103
+ if (imports.length > 0) {
104
+ result += imports[0]
105
+ for (let i = 1; i < imports.length; i++) {
106
+ result += '\n' + imports[i]
107
+ }
108
+ result += '\n\n'
109
+ }
110
+
111
+ return { content: result + typeDefinition, usedHelpers: new Set() }
112
+ }
113
+
114
+ const parserFunction = generateParserFunction(schema, typeName, {
115
+ useRefImports: true,
116
+ ...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
117
+ ...(options?.strict !== undefined ? { strict: options.strict } : {}),
118
+ })
119
+ const shapeValidator = generateShapeValidator(schema, typeName, true)
120
+ const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`
121
+ const helpers: CollectedHelpers = collectHelpers(combinedFunctions, helpersMode)
122
+ const imports = [...collectImports(schema, { selfRef, rootSchema }), ...helpers.imports]
123
+
124
+ // Build file output using string concatenation instead of array join for performance
125
+ let result = ''
126
+
127
+ if (imports.length > 0) {
128
+ result += imports[0]
129
+ for (let i = 1; i < imports.length; i++) {
130
+ result += '\n' + imports[i]
131
+ }
132
+ result += '\n\n'
133
+ }
134
+
135
+ return { content: result + typeDefinition + '\n\n' + combinedFunctions, usedHelpers: helpers.used }
136
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { generateNonDiscriminatedUnionValidation } from './generate-non-discriminated-union-validation'
4
+
5
+ describe('generate-non-discriminated-union-validation', () => {
6
+ it('generates OR chain for multiple schema checks', () => {
7
+ const schemas = [{ type: 'string' as const }, { type: 'number' as const }]
8
+ const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', true)
9
+ expect(result).toContain('typeof value === "string"')
10
+ expect(result).toContain('typeof value === "number"')
11
+ expect(result).toContain('||')
12
+ })
13
+
14
+ it('returns fallback with nullish coalescing when required and no checks', () => {
15
+ const schemas = [{ description: 'no type' }]
16
+ const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', true)
17
+ expect(result).toBe('value ?? ""')
18
+ })
19
+
20
+ it('returns accessor when not required and no checks', () => {
21
+ const schemas = [{ description: 'no type' }]
22
+ const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', false)
23
+ expect(result).toBe('value')
24
+ })
25
+
26
+ it('wraps valid check in ternary with default value when required', () => {
27
+ const schemas = [{ type: 'string' as const }]
28
+ const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', true)
29
+ expect(result).toBe('((typeof value === "string")) ? value : ""')
30
+ })
31
+
32
+ it('uses undefined as fallback when not required', () => {
33
+ const schemas = [{ type: 'string' as const }]
34
+ const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', false)
35
+ expect(result).toBe('((typeof value === "string")) ? value : undefined')
36
+ })
37
+
38
+ it('handles schemas with multiple constraints combined with &&', () => {
39
+ const schemas = [{ type: 'string' as const, minLength: 1, maxLength: 100 }]
40
+ const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', true)
41
+ expect(result).toContain('typeof value === "string"')
42
+ expect(result).toContain('value.length >= 1')
43
+ expect(result).toContain('value.length <= 100')
44
+ expect(result).toContain('&&')
45
+ })
46
+
47
+ it('handles empty schemas array when required', () => {
48
+ const result = generateNonDiscriminatedUnionValidation('value', [], '""', true)
49
+ expect(result).toBe('value ?? ""')
50
+ })
51
+
52
+ it('handles empty schemas array when not required', () => {
53
+ const result = generateNonDiscriminatedUnionValidation('value', [], '""', false)
54
+ expect(result).toBe('value')
55
+ })
56
+
57
+ it('generates multiple OR groups for different types', () => {
58
+ const schemas = [{ type: 'string' as const }, { type: 'number' as const }, { type: 'boolean' as const }]
59
+ const result = generateNonDiscriminatedUnionValidation('value', schemas, 'null', true)
60
+ expect(result).toContain('typeof value === "string"')
61
+ expect(result).toContain('typeof value === "number"')
62
+ expect(result).toContain('typeof value === "boolean"')
63
+ // Should use || between groups
64
+ const orCount = (result.match(/\|\|/g) || []).length
65
+ expect(orCount).toBe(2)
66
+ })
67
+
68
+ it('uses the provided accessor throughout', () => {
69
+ const schemas = [{ type: 'string' as const }]
70
+ const result = generateNonDiscriminatedUnionValidation('input?.name', schemas, '""', true)
71
+ expect(result).toContain('typeof input?.name === "string"')
72
+ expect(result).toContain('input?.name :')
73
+ })
74
+ })
@@ -0,0 +1,40 @@
1
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
2
+
3
+ import { generateSchemaChecks } from './generate-schema-checks'
4
+
5
+ /**
6
+ * Generates a non-discriminated union validation expression.
7
+ * Tries each schema in order until one matches.
8
+ */
9
+ export const generateNonDiscriminatedUnionValidation = (
10
+ accessor: string,
11
+ schemas: readonly JSONSchema[],
12
+ defaultValue: string,
13
+ isRequired: boolean,
14
+ ): string => {
15
+ const cases: string[] = []
16
+
17
+ for (const schema of schemas) {
18
+ const checks = generateSchemaChecks(accessor, schema)
19
+
20
+ if (checks.length > 0) {
21
+ let combinedChecks = checks[0]
22
+ for (let i = 1; i < checks.length; i++) {
23
+ combinedChecks += ' && ' + checks[i]
24
+ }
25
+ cases.push('(' + combinedChecks + ')')
26
+ }
27
+ }
28
+
29
+ if (cases.length === 0) {
30
+ return isRequired ? `${accessor} ?? ${defaultValue}` : accessor
31
+ }
32
+
33
+ // Try each schema in order
34
+ let combinedCheck = cases[0]
35
+ for (let i = 1; i < cases.length; i++) {
36
+ combinedCheck += ' || ' + cases[i]
37
+ }
38
+ const finalDefault = isRequired ? defaultValue : 'undefined'
39
+ return `(${combinedCheck}) ? ${accessor} : ${finalDefault}`
40
+ }