schema-dsl 2.0.0 → 2.0.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 (145) hide show
  1. package/CHANGELOG.md +130 -113
  2. package/LICENSE +21 -21
  3. package/README.md +628 -628
  4. package/dist/{DslBuilder-DkLaOo9Q.d.ts → DslBuilder-BIgQOAXp.d.ts} +2 -0
  5. package/dist/{DslBuilder-DQDN0ZxZ.d.cts → DslBuilder-CjHTucNQ.d.cts} +2 -0
  6. package/dist/{Validator-hFWKGxir.d.ts → Validator-CllRdrY0.d.ts} +1 -1
  7. package/dist/{Validator-C7GsVQOH.d.cts → Validator-D6okG9tr.d.cts} +1 -1
  8. package/dist/index.cjs +75 -29
  9. package/dist/index.d.cts +10 -4
  10. package/dist/index.d.ts +10 -4
  11. package/dist/index.js +75 -29
  12. package/dist/plugins/custom-format.cjs +33 -17
  13. package/dist/plugins/custom-format.d.cts +1 -1
  14. package/dist/plugins/custom-format.d.ts +1 -1
  15. package/dist/plugins/custom-format.js +33 -17
  16. package/dist/plugins/custom-type-example.cjs +33 -17
  17. package/dist/plugins/custom-type-example.d.cts +1 -1
  18. package/dist/plugins/custom-type-example.d.ts +1 -1
  19. package/dist/plugins/custom-type-example.js +33 -17
  20. package/dist/plugins/custom-validator.cjs +0 -2
  21. package/dist/plugins/custom-validator.d.cts +1 -1
  22. package/dist/plugins/custom-validator.d.ts +1 -1
  23. package/dist/plugins/custom-validator.js +0 -2
  24. package/docs/FEATURE-INDEX.md +553 -553
  25. package/docs/add-custom-locale.md +496 -496
  26. package/docs/add-keyword.md +24 -24
  27. package/docs/api-reference.md +1047 -1047
  28. package/docs/api.md +13 -13
  29. package/docs/best-practices-project-structure.md +417 -417
  30. package/docs/best-practices.md +712 -712
  31. package/docs/cache-manager.md +344 -344
  32. package/docs/compile.md +45 -45
  33. package/docs/conditional-api.md +1307 -1307
  34. package/docs/custom-extensions-guide.md +339 -339
  35. package/docs/design-philosophy.md +606 -606
  36. package/docs/doc-index.md +324 -324
  37. package/docs/dsl-syntax.md +714 -714
  38. package/docs/dynamic-locale.md +608 -608
  39. package/docs/enum.md +482 -482
  40. package/docs/error-handling.md +1975 -1975
  41. package/docs/export-guide.md +501 -501
  42. package/docs/export-limitations.md +567 -567
  43. package/docs/faq.md +596 -596
  44. package/docs/frontend-i18n-guide.md +307 -307
  45. package/docs/i18n-user-guide.md +487 -487
  46. package/docs/i18n.md +476 -476
  47. package/docs/index.md +48 -48
  48. package/docs/json-schema-basics.md +40 -40
  49. package/docs/label-vs-description.md +271 -271
  50. package/docs/markdown-exporter.md +406 -406
  51. package/docs/mongodb-exporter.md +302 -302
  52. package/docs/multi-language.md +26 -26
  53. package/docs/multi-type-support.md +322 -322
  54. package/docs/mysql-exporter.md +280 -280
  55. package/docs/number-operators.md +449 -449
  56. package/docs/optional-marker-guide.md +326 -326
  57. package/docs/performance-guide.md +49 -49
  58. package/docs/plugin-system.md +381 -381
  59. package/docs/plugin-type-registration.md +34 -34
  60. package/docs/postgresql-exporter.md +311 -311
  61. package/docs/public/favicon.svg +4 -4
  62. package/docs/quick-start.md +435 -435
  63. package/docs/runtime-locale-support.md +532 -532
  64. package/docs/schema-helper.md +345 -345
  65. package/docs/schema-utils-advanced-issues.md +23 -23
  66. package/docs/schema-utils-best-practices.md +20 -20
  67. package/docs/schema-utils-chaining.md +150 -150
  68. package/docs/schema-utils.md +524 -524
  69. package/docs/security-checklist.md +20 -20
  70. package/docs/string-extensions.md +488 -488
  71. package/docs/troubleshooting.md +486 -486
  72. package/docs/type-converter.md +310 -310
  73. package/docs/type-reference.md +242 -242
  74. package/docs/typescript-guide.md +584 -584
  75. package/docs/union-type-guide.md +157 -157
  76. package/docs/union-types.md +284 -284
  77. package/docs/validate-async.md +491 -491
  78. package/docs/validate-batch.md +49 -49
  79. package/docs/validate-dsl-object-support.md +578 -578
  80. package/docs/validate.md +506 -506
  81. package/docs/validation-guide.md +502 -502
  82. package/docs/validator.md +39 -39
  83. package/package.json +131 -131
  84. package/plugins/custom-format.cjs +8 -8
  85. package/plugins/custom-type-example.cjs +8 -8
  86. package/plugins/custom-validator.cjs +8 -8
  87. package/src/adapters/DslAdapter.ts +111 -111
  88. package/src/adapters/index.ts +1 -1
  89. package/src/config/constants.ts +83 -83
  90. package/src/config/index.ts +2 -2
  91. package/src/config/patterns.ts +77 -77
  92. package/src/core/CacheManager.ts +169 -159
  93. package/src/core/ConditionalBuilder.ts +382 -382
  94. package/src/core/ConditionalRuntime.ts +27 -27
  95. package/src/core/ConditionalValidator.ts +254 -254
  96. package/src/core/DslBuilder.ts +687 -677
  97. package/src/core/ErrorCodes.ts +38 -38
  98. package/src/core/ErrorFormatter.ts +271 -271
  99. package/src/core/JSONSchemaCore.ts +65 -65
  100. package/src/core/Locale.ts +187 -187
  101. package/src/core/MessageTemplate.ts +42 -42
  102. package/src/core/ObjectDslBuilder.ts +64 -64
  103. package/src/core/PluginManager.ts +326 -326
  104. package/src/core/StringExtensions.ts +140 -140
  105. package/src/core/TemplateEngine.ts +44 -44
  106. package/src/core/Validator.ts +448 -448
  107. package/src/errors/I18nError.ts +159 -159
  108. package/src/errors/ValidationError.ts +105 -105
  109. package/src/exporters/BaseExporter.ts +60 -60
  110. package/src/exporters/MarkdownExporter.ts +305 -305
  111. package/src/exporters/MongoDBExporter.ts +126 -126
  112. package/src/exporters/MySQLExporter.ts +156 -155
  113. package/src/exporters/PostgreSQLExporter.ts +222 -222
  114. package/src/exporters/index.ts +18 -18
  115. package/src/index.ts +651 -633
  116. package/src/locales/en-US.ts +160 -160
  117. package/src/locales/es-ES.ts +160 -160
  118. package/src/locales/fr-FR.ts +160 -160
  119. package/src/locales/index.ts +103 -103
  120. package/src/locales/ja-JP.ts +160 -160
  121. package/src/locales/types.ts +156 -156
  122. package/src/locales/zh-CN.ts +160 -160
  123. package/src/parser/ConstraintParser.ts +101 -101
  124. package/src/parser/DslParser.ts +470 -470
  125. package/src/parser/SchemaCompiler.ts +66 -66
  126. package/src/parser/TypeRegistry.ts +250 -250
  127. package/src/parser/index.ts +6 -6
  128. package/src/plugins/custom-format.ts +124 -126
  129. package/src/plugins/custom-type-example.ts +106 -108
  130. package/src/plugins/custom-validator.ts +138 -140
  131. package/src/types/conditional.ts +28 -28
  132. package/src/types/config.ts +59 -59
  133. package/src/types/dsl.ts +131 -131
  134. package/src/types/error.ts +60 -60
  135. package/src/types/index.ts +17 -17
  136. package/src/types/infer.ts +127 -127
  137. package/src/types/plugin.ts +58 -58
  138. package/src/types/safe-regex.d.ts +9 -9
  139. package/src/types/schema.ts +66 -66
  140. package/src/types/validate.ts +71 -71
  141. package/src/utils/SchemaHelper.ts +196 -196
  142. package/src/utils/SchemaUtils.ts +365 -346
  143. package/src/utils/TypeConverter.ts +215 -215
  144. package/src/utils/index.ts +10 -10
  145. package/src/validators/CustomKeywords.ts +477 -477
@@ -1,677 +1,687 @@
1
- /**
2
- * DslBuilder — chainable DSL builder.
3
- *
4
- * v2 changes:
5
- * - Constructor delegates to DslParser.parseString() (fixes DA-01/DA-02/DA-03)
6
- * - Custom type registration delegates to TypeRegistry (fixes DB-01/DB-02: unifies three type lists)
7
- * - _customMessages merges instead of overwriting (fixes v1 overwrite bug)
8
- * - Implements IDslBuilder interface (error/optional/required/enum chain methods)
9
- */
10
-
11
- import type { JSONSchema } from '../types/schema.js'
12
- import type { IDslBuilder } from '../types/dsl.js'
13
- import { DslParser } from '../parser/DslParser.js'
14
- import { TypeRegistry } from '../parser/TypeRegistry.js'
15
- import { PATTERNS } from '../config/patterns.js'
16
- import type { Validator as ValidatorInstance } from './Validator.js'
17
- import type { ValidationResult } from '../types/validate.js'
18
-
19
- // ==================== Internal Utilities ====================
20
-
21
- type CustomValidatorFn = (value: unknown) => unknown
22
-
23
- /** Password strength presets. */
24
- const PASSWORD_PATTERNS: Record<string, RegExp> = {
25
- weak: /.{6,}/,
26
- medium: /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/,
27
- strong: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/,
28
- veryStrong: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{10,}$/,
29
- }
30
- const PASSWORD_MIN_LENGTHS: Record<string, number> = {
31
- weak: 6, medium: 8, strong: 8, veryStrong: 10,
32
- }
33
-
34
- // ==================== DslBuilder ====================
35
-
36
- export class DslBuilder implements IDslBuilder {
37
- // Required IDslBuilder field
38
- readonly _isDslBuilder = true as const
39
-
40
- /** schema-dsl custom validation keyword set (stripped during toJsonSchema). */
41
- static readonly _internalKeys: ReadonlySet<string> = TypeRegistry.getInternalKeys()
42
-
43
- /** Custom type cache (BC with v1 DslBuilder._customTypes). */
44
- private static readonly _customTypes = new Map<string, JSONSchema | (() => JSONSchema)>()
45
-
46
- private _baseSchema: JSONSchema
47
- private _required: boolean
48
- private _optional: boolean
49
- private _customMessages: Record<string, string>
50
- private _label: string | null
51
- private _description: string | null
52
- private _customValidators: CustomValidatorFn[]
53
- private _whenConditions: unknown[]
54
-
55
- // ==================== Constructor ====================
56
-
57
- constructor(dslString: string) {
58
- if (!dslString || typeof dslString !== 'string') {
59
- throw new Error('[schema-dsl] DSL string is required')
60
- }
61
-
62
- let s = dslString.trim()
63
-
64
- // array!N-M special syntax (v1 compat) → array:N-M + required=true
65
- const arrayBangMatch = /^array!([\d-]+)$/.exec(s)
66
- if (arrayBangMatch) {
67
- s = `array:${arrayBangMatch[1]}`
68
- this._required = true
69
- this._optional = false
70
- } else {
71
- this._required = s.endsWith('!')
72
- this._optional = s.endsWith('?') && !this._required
73
- if (this._required || this._optional) s = s.slice(0, -1)
74
- }
75
-
76
- this._customMessages = {}
77
- this._label = null
78
- this._description = null
79
- this._customValidators = []
80
- this._whenConditions = []
81
-
82
- this._baseSchema = DslBuilder._parseBody(s)
83
- }
84
-
85
- // ==================== Internal Parsing ====================
86
-
87
- /**
88
- * Parse DSL body (without ! or ?).
89
- * Delegates to the unified parser so string and builder DSL parsing stay in lockstep.
90
- */
91
- private static _parseBody(dsl: string): JSONSchema {
92
- return DslParser.parseString(dsl)
93
- }
94
-
95
- // ==================== Static Methods (BC with v1) ====================
96
-
97
- /**
98
- * Register a custom type (delegates to TypeRegistry).
99
- */
100
- static registerType(name: string, schema: JSONSchema | (() => JSONSchema)): void {
101
- if (!name || typeof name !== 'string') {
102
- throw new Error('[schema-dsl] Type name must be a non-empty string')
103
- }
104
- if (!schema || (typeof schema !== 'object' && typeof schema !== 'function')) {
105
- throw new Error('[schema-dsl] Schema must be an object or function')
106
- }
107
- DslBuilder._customTypes.set(name, schema)
108
- if (typeof schema === 'function') {
109
- // Store function as a dynamic type — resolved on each access
110
- TypeRegistry.registerDynamic(name, schema)
111
- } else {
112
- TypeRegistry.register(name, schema)
113
- }
114
- }
115
-
116
- /** Check whether a type is registered (built-in or custom). */
117
- static hasType(type: string): boolean {
118
- return TypeRegistry.has(type)
119
- }
120
-
121
- /** Get all registered custom type names. */
122
- static getCustomTypes(): string[] {
123
- return Array.from(DslBuilder._customTypes.keys())
124
- }
125
-
126
- /** Clear all custom types (primarily for testing). */
127
- static clearCustomTypes(): void {
128
- TypeRegistry.clearCustomTypes()
129
- DslBuilder._customTypes.clear()
130
- }
131
-
132
- /**
133
- * Validate schema nesting depth.
134
- * @param schema - JSON Schema to validate
135
- * @param maxDepth - maximum allowed depth (default 3)
136
- */
137
- static validateNestingDepth(
138
- schema: JSONSchema,
139
- maxDepth = 3,
140
- ): { valid: boolean; depth: number; path: string; message: string } {
141
- let maxFound = 0
142
- let deepestPath = ''
143
-
144
- function traverse(obj: JSONSchema, depth: number, path: string, isRoot: boolean): void {
145
- if (!isRoot && (obj.properties || obj.items)) {
146
- if (depth > maxFound) {
147
- maxFound = depth
148
- deepestPath = path
149
- }
150
- }
151
- if (obj.properties) {
152
- const nextDepth = depth + 1
153
- for (const key of Object.keys(obj.properties)) {
154
- traverse(
155
- (obj.properties as Record<string, JSONSchema>)[key],
156
- nextDepth,
157
- `${path}.${key}`.replace(/^\./, ''),
158
- false,
159
- )
160
- }
161
- }
162
- if (obj.items && !Array.isArray(obj.items)) {
163
- traverse(obj.items as JSONSchema, depth, `${path}[]`, false)
164
- }
165
- }
166
-
167
- traverse(schema, 0, '', true)
168
-
169
- return {
170
- valid: maxFound <= maxDepth,
171
- depth: maxFound,
172
- path: deepestPath,
173
- message:
174
- maxFound > maxDepth
175
- ? `Nesting depth ${maxFound} exceeds limit ${maxDepth}, path: ${deepestPath}`
176
- : `Nesting depth ${maxFound} is within the limit`,
177
- }
178
- }
179
-
180
- // ==================== Private Utilities ====================
181
-
182
- private _assertType(method: string, ...types: string[]): void {
183
- const t = this._baseSchema.type as string
184
- if (!types.includes(t)) {
185
- throw new Error(`[schema-dsl] ${method}() only applies to ${types.join('/')} type`)
186
- }
187
- }
188
-
189
- private _assertStringType(method: string): void {
190
- this._assertType(method, 'string')
191
- }
192
-
193
- private _assertNumberType(method: string): void {
194
- this._assertType(method, 'number', 'integer')
195
- }
196
-
197
- private _assertObjectType(method: string): void {
198
- this._assertType(method, 'object')
199
- }
200
-
201
- private _assertArrayType(method: string): void {
202
- this._assertType(method, 'array')
203
- }
204
-
205
- // ==================== Common Chain Methods ====================
206
-
207
- /**
208
- * Set format.
209
- */
210
- format(fmt: string): this {
211
- this._baseSchema.format = fmt
212
- return this
213
- }
214
-
215
- /**
216
- * Add regex validation.
217
- */
218
- pattern(regex: RegExp | string, message?: string): this {
219
- this._baseSchema.pattern = regex instanceof RegExp ? regex.source : regex
220
- if (message) {
221
- this._customMessages['string.pattern'] = message
222
- }
223
- return this
224
- }
225
-
226
- /**
227
- * Custom error messages (IDslBuilder: error; BC alias: messages).
228
- */
229
- messages(msgs: Record<string, string>): this {
230
- Object.assign(this._customMessages, msgs)
231
- return this
232
- }
233
-
234
- /** IDslBuilder.error — alias for messages() */
235
- error(msgs: Record<string, string>): this {
236
- return this.messages(msgs)
237
- }
238
-
239
- /**
240
- * Set field label (used in error messages).
241
- */
242
- label(text: string): this {
243
- this._label = text
244
- return this
245
- }
246
-
247
- /**
248
- * Set description.
249
- */
250
- description(text: string): this {
251
- this._description = text
252
- return this
253
- }
254
-
255
- /**
256
- * Set default value.
257
- */
258
- default(value: unknown): this {
259
- this._baseSchema.default = value
260
- return this
261
- }
262
-
263
- /**
264
- * Set allowed enum values (IDslBuilder).
265
- */
266
- enum(...values: unknown[]): this {
267
- this._baseSchema.enum = values
268
- return this
269
- }
270
-
271
- /**
272
- * Mark field as optional.
273
- */
274
- optional(): this {
275
- this._required = false
276
- this._optional = true
277
- return this
278
- }
279
-
280
- /**
281
- * Mark field as required.
282
- */
283
- required(): this {
284
- this._required = true
285
- this._optional = false
286
- return this
287
- }
288
-
289
- /**
290
- * Add a custom validator function.
291
- */
292
- custom(validatorFn: CustomValidatorFn): this {
293
- if (typeof validatorFn !== 'function') {
294
- throw new Error('[schema-dsl] Custom validator must be a function')
295
- }
296
- this._customValidators.push(validatorFn)
297
- return this
298
- }
299
-
300
- // ==================== String Chain Methods ====================
301
-
302
- /** String minimum length. */
303
- min(n: number): this {
304
- this._assertStringType('min')
305
- this._baseSchema.minLength = n
306
- return this
307
- }
308
-
309
- /** String maximum length. */
310
- max(n: number): this {
311
- this._assertStringType('max')
312
- this._baseSchema.maxLength = n
313
- return this
314
- }
315
-
316
- /** String exact length (→ exactLength custom keyword). */
317
- length(n: number): this {
318
- this._assertStringType('length')
319
- this._baseSchema.exactLength = n
320
- return this
321
- }
322
-
323
- /** String: only alphanumeric characters allowed. */
324
- alphanum(): this {
325
- this._assertStringType('alphanum')
326
- this._baseSchema.alphanum = true
327
- return this
328
- }
329
-
330
- /** String: no leading/trailing whitespace. */
331
- trim(): this {
332
- this._assertStringType('trim')
333
- this._baseSchema.trim = true
334
- return this
335
- }
336
-
337
- /** String: must be lowercase. */
338
- lowercase(): this {
339
- this._assertStringType('lowercase')
340
- this._baseSchema.lowercase = true
341
- return this
342
- }
343
-
344
- /** String: must be uppercase. */
345
- uppercase(): this {
346
- this._assertStringType('uppercase')
347
- this._baseSchema.uppercase = true
348
- return this
349
- }
350
-
351
- /** String: must be a valid JSON string. */
352
- json(): this {
353
- this._assertStringType('json')
354
- this._baseSchema.jsonString = true
355
- return this
356
- }
357
-
358
- /** String date format validation. */
359
- dateFormat(fmt: string): this {
360
- this._assertStringType('dateFormat')
361
- this._baseSchema.dateFormat = fmt
362
- return this
363
- }
364
-
365
- /** String: must be after the given date. */
366
- after(date: string): this {
367
- this._assertStringType('after')
368
- this._baseSchema.dateGreater = date
369
- return this
370
- }
371
-
372
- /** String: must be before the given date. */
373
- before(date: string): this {
374
- this._assertStringType('before')
375
- this._baseSchema.dateLess = date
376
- return this
377
- }
378
-
379
- /** v1.0.2 alias: dateGreater. */
380
- dateGreater(date: string): this {
381
- this._assertStringType('dateGreater')
382
- this._baseSchema.dateGreater = date
383
- return this
384
- }
385
-
386
- /** v1.0.2 alias: dateLess. */
387
- dateLess(date: string): this {
388
- this._assertStringType('dateLess')
389
- this._baseSchema.dateLess = date
390
- return this
391
- }
392
-
393
- /** String slug format validation. */
394
- slug(): this {
395
- this._assertStringType('slug')
396
- this._baseSchema.pattern = '^[a-z0-9]+(?:-[a-z0-9]+)*$'
397
- const existing = (this._baseSchema._customMessages as Record<string, string> | undefined) || {}
398
- this._baseSchema._customMessages = { ...existing, pattern: 'pattern.slug' }
399
- return this
400
- }
401
-
402
- /** String domain validation. */
403
- domain(): this {
404
- this._assertStringType('domain')
405
- const cfg = PATTERNS.common.domain
406
- return this.pattern(cfg.pattern).messages({ pattern: cfg.key })
407
- }
408
-
409
- /** String IP address validation (IPv4 or IPv6). */
410
- ip(): this {
411
- this._assertStringType('ip')
412
- const cfg = PATTERNS.common.ip
413
- return this.pattern(cfg.pattern).messages({ pattern: cfg.key })
414
- }
415
-
416
- /** String Base64 encoding validation. */
417
- base64(): this {
418
- this._assertStringType('base64')
419
- const cfg = PATTERNS.common.base64
420
- return this.pattern(cfg.pattern).messages({ pattern: cfg.key })
421
- }
422
-
423
- /** String JWT token validation. */
424
- jwt(): this {
425
- this._assertStringType('jwt')
426
- const cfg = PATTERNS.common.jwt
427
- return this.pattern(cfg.pattern).messages({ pattern: cfg.key })
428
- }
429
-
430
- // ==================== Identity / Pattern Chain Methods ====================
431
-
432
- /** Phone number validation (auto-corrects number → string). */
433
- phone(country = 'cn'): this {
434
- // Auto-correct type
435
- if (this._baseSchema.type === 'number' || this._baseSchema.type === 'integer') {
436
- this._baseSchema.type = 'string'
437
- delete (this._baseSchema as Record<string, unknown>)['minimum']
438
- delete (this._baseSchema as Record<string, unknown>)['maximum']
439
- }
440
- const cfg = PATTERNS.phone[country]
441
- if (!cfg) throw new Error(`[schema-dsl] Unsupported country: ${country}`)
442
- if (cfg.min !== undefined && !this._baseSchema.minLength) this._baseSchema.minLength = cfg.min
443
- if (cfg.max !== undefined && !this._baseSchema.maxLength) this._baseSchema.maxLength = cfg.max
444
- return this.pattern(cfg.pattern).messages({ pattern: cfg.key })
445
- }
446
-
447
- /** phone() alias (BC). */
448
- phoneNumber(country = 'cn'): this {
449
- return this.phone(country)
450
- }
451
-
452
- /** National ID (idCard) validation. */
453
- idCard(country = 'cn'): this {
454
- const lower = country.toLowerCase()
455
- const cfg = PATTERNS.idCard[lower]
456
- if (!cfg) throw new Error(`[schema-dsl] Unsupported country for idCard: ${country}`)
457
- if (cfg.min !== undefined && !this._baseSchema.minLength) this._baseSchema.minLength = cfg.min
458
- if (cfg.max !== undefined && !this._baseSchema.maxLength) this._baseSchema.maxLength = cfg.max
459
- return this.pattern(cfg.pattern).messages({ pattern: cfg.key })
460
- }
461
-
462
- /** URL slug validation. */
463
- slugChain(): this {
464
- return this.pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).messages({ pattern: 'pattern.slug' })
465
- }
466
-
467
- /** Credit card number validation. */
468
- creditCard(type = 'visa'): this {
469
- const cfg = PATTERNS.creditCard[type.toLowerCase()]
470
- if (!cfg) throw new Error(`[schema-dsl] Unsupported credit card type: ${type}`)
471
- return this.pattern(cfg.pattern).messages({ pattern: cfg.key })
472
- }
473
-
474
- /** Vehicle license plate validation. */
475
- licensePlate(country = 'cn'): this {
476
- const cfg = PATTERNS.licensePlate[country.toLowerCase()]
477
- if (!cfg) throw new Error(`[schema-dsl] Unsupported country for licensePlate: ${country}`)
478
- return this.pattern(cfg.pattern).messages({ pattern: cfg.key })
479
- }
480
-
481
- /** Postal code validation. */
482
- postalCode(country = 'cn'): this {
483
- const cfg = PATTERNS.postalCode[country.toLowerCase()]
484
- if (!cfg) throw new Error(`[schema-dsl] Unsupported country for postalCode: ${country}`)
485
- return this.pattern(cfg.pattern).messages({ pattern: cfg.key })
486
- }
487
-
488
- /** Passport number validation. */
489
- passport(country = 'cn'): this {
490
- const cfg = PATTERNS.passport[country.toLowerCase()]
491
- if (!cfg) throw new Error(`[schema-dsl] Unsupported country for passport: ${country}`)
492
- return this.pattern(cfg.pattern).messages({ pattern: cfg.key })
493
- }
494
-
495
- /**
496
- * Username validation.
497
- * @param preset - 'short'(3-16) | 'medium'(3-32) | 'long'(3-64) | 'N-M' | object
498
- */
499
- username(preset: string | { minLength?: number; maxLength?: number; allowUnderscore?: boolean; allowNumber?: boolean } = 'medium'): this {
500
- let minLength: number
501
- let maxLength: number
502
- let allowUnderscore = true
503
- let allowNumber = true
504
-
505
- if (typeof preset === 'string') {
506
- const rangeMatch = /^(\d+)-(\d+)$/.exec(preset)
507
- if (rangeMatch) {
508
- minLength = parseInt(rangeMatch[1], 10)
509
- maxLength = parseInt(rangeMatch[2], 10)
510
- } else {
511
- const presets: Record<string, { min: number; max: number }> = {
512
- short: { min: 3, max: 16 },
513
- medium: { min: 3, max: 32 },
514
- long: { min: 3, max: 64 },
515
- }
516
- const p = presets[preset] ?? presets['medium']
517
- minLength = p.min
518
- maxLength = p.max
519
- }
520
- } else {
521
- minLength = preset.minLength ?? 3
522
- maxLength = preset.maxLength ?? 32
523
- allowUnderscore = preset.allowUnderscore !== false
524
- allowNumber = preset.allowNumber !== false
525
- }
526
-
527
- if (!this._baseSchema.minLength) this._baseSchema.minLength = minLength
528
- if (!this._baseSchema.maxLength) this._baseSchema.maxLength = maxLength
529
-
530
- let pat = '^[a-zA-Z]'
531
- if (allowUnderscore && allowNumber) {
532
- pat += '[a-zA-Z0-9_]*$'
533
- } else if (allowNumber) {
534
- pat += '[a-zA-Z0-9]*$'
535
- } else {
536
- pat += '[a-zA-Z]*$'
537
- }
538
-
539
- return this.pattern(new RegExp(pat)).messages({ pattern: 'pattern.username' })
540
- }
541
-
542
- /**
543
- * Password strength validation.
544
- * @param strength - 'weak' | 'medium' | 'strong' | 'veryStrong'
545
- */
546
- password(strength = 'medium'): this {
547
- const pat = PASSWORD_PATTERNS[strength]
548
- if (!pat) throw new Error(`[schema-dsl] Invalid password strength: ${strength}`)
549
- if (!this._baseSchema.minLength) this._baseSchema.minLength = PASSWORD_MIN_LENGTHS[strength]
550
- if (!this._baseSchema.maxLength) this._baseSchema.maxLength = 64
551
- return this.pattern(pat).messages({ pattern: `pattern.password.${strength}` })
552
- }
553
-
554
- // ==================== Number Chain Methods ====================
555
-
556
- /** Number decimal places limit. */
557
- precision(n: number): this {
558
- this._assertNumberType('precision')
559
- this._baseSchema.precision = n
560
- return this
561
- }
562
-
563
- /** Number multiple-of validation (standard JSON Schema multipleOf). */
564
- multiple(n: number): this {
565
- this._assertNumberType('multiple')
566
- this._baseSchema.multipleOf = n
567
- return this
568
- }
569
-
570
- /** Number port validation (1–65535). */
571
- port(): this {
572
- this._assertNumberType('port')
573
- this._baseSchema.port = true
574
- return this
575
- }
576
-
577
- // ==================== Object Chain Methods ====================
578
-
579
- /** Object: all defined properties are required. */
580
- requireAll(): this {
581
- this._assertObjectType('requireAll')
582
- this._baseSchema.requiredAll = true
583
- return this
584
- }
585
-
586
- /** Object strict mode: no additional properties allowed. */
587
- strict(): this {
588
- this._assertObjectType('strict')
589
- this._baseSchema.strictSchema = true
590
- return this
591
- }
592
-
593
- // ==================== Array Chain Methods ====================
594
-
595
- /** Array: sparse arrays are not allowed. */
596
- noSparse(): this {
597
- this._assertArrayType('noSparse')
598
- this._baseSchema.noSparse = true
599
- return this
600
- }
601
-
602
- /** Array: must contain the specified element. */
603
- includesRequired(items: unknown[]): this {
604
- this._assertArrayType('includesRequired')
605
- if (!Array.isArray(items)) {
606
- throw new Error('[schema-dsl] includesRequired() requires an array parameter')
607
- }
608
- this._baseSchema.includesRequired = items
609
- return this
610
- }
611
-
612
- // ==================== Output Methods ====================
613
-
614
- /**
615
- * Convert to a schema with schema-dsl internal fields (for use by Validator).
616
- */
617
- toSchema(): JSONSchema {
618
- const schema: JSONSchema = { ...this._baseSchema }
619
-
620
- if (this._description) {
621
- schema.description = this._description
622
- }
623
-
624
- // Merge _customMessages: base type messages + user custom messages (user takes priority)
625
- const baseCustomMsgs = (schema._customMessages as Record<string, string> | undefined) || {}
626
- const mergedMsgs = { ...baseCustomMsgs, ...this._customMessages }
627
- if (Object.keys(mergedMsgs).length > 0) {
628
- schema._customMessages = mergedMsgs
629
- } else {
630
- delete (schema as Record<string, unknown>)['_customMessages']
631
- }
632
-
633
- if (this._label) {
634
- schema._label = this._label
635
- }
636
-
637
- if (this._customValidators.length > 0) {
638
- schema._customValidators = this._customValidators as unknown[]
639
- }
640
-
641
- if (this._whenConditions.length > 0) {
642
- schema._whenConditions = this._whenConditions
643
- }
644
-
645
- // Always output _required (BC with v1: output even when false)
646
- schema._required = this._required
647
-
648
- return schema
649
- }
650
-
651
- /**
652
- * Output a clean JSON Schema (strips all schema-dsl internal fields and custom keywords).
653
- * Can be embedded directly in OpenAPI / standard JSON Schema documents.
654
- */
655
- toJsonSchema(): JSONSchema {
656
- return TypeRegistry.toJsonSchema(this.toSchema())
657
- }
658
-
659
- toString(): string {
660
- return JSON.stringify(this.toJsonSchema())
661
- }
662
-
663
- /**
664
- * Validate data (BC with v1).
665
- * @param data - data to validate
666
- */
667
- private _validator: ValidatorInstance | null = null
668
-
669
- async validate(data: unknown): Promise<ValidationResult<unknown>> {
670
- if (!this._validator) {
671
- const { Validator } = await import('./Validator.js')
672
- this._validator = new Validator()
673
- }
674
- const schema = this.toSchema()
675
- return this._validator.validate(schema, data)
676
- }
677
- }
1
+ /**
2
+ * DslBuilder — chainable DSL builder.
3
+ *
4
+ * v2 changes:
5
+ * - Constructor delegates to DslParser.parseString() (fixes DA-01/DA-02/DA-03)
6
+ * - Custom type registration delegates to TypeRegistry (fixes DB-01/DB-02: unifies three type lists)
7
+ * - _customMessages merges instead of overwriting (fixes v1 overwrite bug)
8
+ * - Implements IDslBuilder interface (error/optional/required/enum chain methods)
9
+ */
10
+
11
+ import type { JSONSchema } from '../types/schema.js'
12
+ import type { IDslBuilder } from '../types/dsl.js'
13
+ import { DslParser } from '../parser/DslParser.js'
14
+ import { TypeRegistry } from '../parser/TypeRegistry.js'
15
+ import { PATTERNS } from '../config/patterns.js'
16
+ import safeRegex from 'safe-regex'
17
+ import type { Validator as ValidatorInstance } from './Validator.js'
18
+ import type { ValidationResult } from '../types/validate.js'
19
+
20
+ // ==================== Internal Utilities ====================
21
+
22
+ type CustomValidatorFn = (value: unknown) => unknown
23
+
24
+ /** Password strength presets. */
25
+ const PASSWORD_PATTERNS: Record<string, RegExp> = {
26
+ weak: /.{6,}/,
27
+ medium: /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/,
28
+ strong: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/,
29
+ veryStrong: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{10,}$/,
30
+ }
31
+ const PASSWORD_MIN_LENGTHS: Record<string, number> = {
32
+ weak: 6, medium: 8, strong: 8, veryStrong: 10,
33
+ }
34
+
35
+ // ==================== DslBuilder ====================
36
+
37
+ export class DslBuilder implements IDslBuilder {
38
+ // Required IDslBuilder field
39
+ readonly _isDslBuilder = true as const
40
+
41
+ /** schema-dsl custom validation keyword set (stripped during toJsonSchema). */
42
+ static readonly _internalKeys: ReadonlySet<string> = TypeRegistry.getInternalKeys()
43
+
44
+ /** Custom type cache (BC with v1 DslBuilder._customTypes). */
45
+ private static readonly _customTypes = new Map<string, JSONSchema | (() => JSONSchema)>()
46
+
47
+ private _baseSchema: JSONSchema
48
+ private _required: boolean
49
+ private _optional: boolean
50
+ private _customMessages: Record<string, string>
51
+ private _label: string | null
52
+ private _description: string | null
53
+ private _customValidators: CustomValidatorFn[]
54
+ private _whenConditions: unknown[]
55
+
56
+ // ==================== Constructor ====================
57
+
58
+ constructor(dslString: string) {
59
+ if (!dslString || typeof dslString !== 'string') {
60
+ throw new Error('[schema-dsl] DSL string is required')
61
+ }
62
+
63
+ let s = dslString.trim()
64
+
65
+ // array!N-M special syntax (v1 compat) → array:N-M + required=true
66
+ const arrayBangMatch = /^array!([\d-]+)$/.exec(s)
67
+ if (arrayBangMatch) {
68
+ s = `array:${arrayBangMatch[1]}`
69
+ this._required = true
70
+ this._optional = false
71
+ } else {
72
+ this._required = s.endsWith('!')
73
+ this._optional = s.endsWith('?') && !this._required
74
+ if (this._required || this._optional) s = s.slice(0, -1)
75
+ }
76
+
77
+ this._customMessages = {}
78
+ this._label = null
79
+ this._description = null
80
+ this._customValidators = []
81
+ this._whenConditions = []
82
+
83
+ this._baseSchema = DslBuilder._parseBody(s)
84
+ }
85
+
86
+ // ==================== Internal Parsing ====================
87
+
88
+ /**
89
+ * Parse DSL body (without ! or ?).
90
+ * Delegates to the unified parser so string and builder DSL parsing stay in lockstep.
91
+ */
92
+ private static _parseBody(dsl: string): JSONSchema {
93
+ return DslParser.parseString(dsl)
94
+ }
95
+
96
+ // ==================== Static Methods (BC with v1) ====================
97
+
98
+ /**
99
+ * Register a custom type (delegates to TypeRegistry).
100
+ */
101
+ static registerType(name: string, schema: JSONSchema | (() => JSONSchema)): void {
102
+ if (!name || typeof name !== 'string') {
103
+ throw new Error('[schema-dsl] Type name must be a non-empty string')
104
+ }
105
+ if (!schema || (typeof schema !== 'object' && typeof schema !== 'function')) {
106
+ throw new Error('[schema-dsl] Schema must be an object or function')
107
+ }
108
+ DslBuilder._customTypes.set(name, schema)
109
+ if (typeof schema === 'function') {
110
+ // Store function as a dynamic type — resolved on each access
111
+ TypeRegistry.registerDynamic(name, schema)
112
+ } else {
113
+ TypeRegistry.register(name, schema)
114
+ }
115
+ }
116
+
117
+ /** Check whether a type is registered (built-in or custom). */
118
+ static hasType(type: string): boolean {
119
+ return TypeRegistry.has(type)
120
+ }
121
+
122
+ /** Get all registered custom type names. */
123
+ static getCustomTypes(): string[] {
124
+ return Array.from(DslBuilder._customTypes.keys())
125
+ }
126
+
127
+ /** Clear all custom types (primarily for testing). */
128
+ static clearCustomTypes(): void {
129
+ TypeRegistry.clearCustomTypes()
130
+ DslBuilder._customTypes.clear()
131
+ }
132
+
133
+ /**
134
+ * Validate schema nesting depth.
135
+ * @param schema - JSON Schema to validate
136
+ * @param maxDepth - maximum allowed depth (default 3)
137
+ */
138
+ static validateNestingDepth(
139
+ schema: JSONSchema,
140
+ maxDepth = 3,
141
+ ): { valid: boolean; depth: number; path: string; message: string } {
142
+ let maxFound = 0
143
+ let deepestPath = ''
144
+
145
+ function traverse(obj: JSONSchema, depth: number, path: string, isRoot: boolean): void {
146
+ if (!isRoot && (obj.properties || obj.items)) {
147
+ if (depth > maxFound) {
148
+ maxFound = depth
149
+ deepestPath = path
150
+ }
151
+ }
152
+ if (obj.properties) {
153
+ const nextDepth = depth + 1
154
+ for (const key of Object.keys(obj.properties)) {
155
+ traverse(
156
+ (obj.properties as Record<string, JSONSchema>)[key],
157
+ nextDepth,
158
+ `${path}.${key}`.replace(/^\./, ''),
159
+ false,
160
+ )
161
+ }
162
+ }
163
+ if (obj.items && !Array.isArray(obj.items)) {
164
+ traverse(obj.items as JSONSchema, depth, `${path}[]`, false)
165
+ }
166
+ }
167
+
168
+ traverse(schema, 0, '', true)
169
+
170
+ return {
171
+ valid: maxFound <= maxDepth,
172
+ depth: maxFound,
173
+ path: deepestPath,
174
+ message:
175
+ maxFound > maxDepth
176
+ ? `Nesting depth ${maxFound} exceeds limit ${maxDepth}, path: ${deepestPath}`
177
+ : `Nesting depth ${maxFound} is within the limit`,
178
+ }
179
+ }
180
+
181
+ // ==================== Private Utilities ====================
182
+
183
+ private _assertType(method: string, ...types: string[]): void {
184
+ const t = this._baseSchema.type as string
185
+ if (!types.includes(t)) {
186
+ throw new Error(`[schema-dsl] ${method}() only applies to ${types.join('/')} type`)
187
+ }
188
+ }
189
+
190
+ private _assertStringType(method: string): void {
191
+ this._assertType(method, 'string')
192
+ }
193
+
194
+ private _assertNumberType(method: string): void {
195
+ this._assertType(method, 'number', 'integer')
196
+ }
197
+
198
+ private _assertObjectType(method: string): void {
199
+ this._assertType(method, 'object')
200
+ }
201
+
202
+ private _assertArrayType(method: string): void {
203
+ this._assertType(method, 'array')
204
+ }
205
+
206
+ // ==================== Common Chain Methods ====================
207
+
208
+ /**
209
+ * Set format.
210
+ */
211
+ format(fmt: string): this {
212
+ this._baseSchema.format = fmt
213
+ return this
214
+ }
215
+
216
+ /**
217
+ * Add regex validation.
218
+ */
219
+ pattern(regex: RegExp | string, message?: string): this {
220
+ const source = regex instanceof RegExp ? regex.source : regex
221
+ if (!safeRegex(source)) {
222
+ throw new Error(`[schema-dsl] Unsafe regex pattern rejected (potential ReDoS): ${source}`)
223
+ }
224
+ return this._setPattern(source, message)
225
+ }
226
+
227
+ /** Internal: set pattern without safe-regex check (used by built-in validators with pre-approved patterns). */
228
+ private _setPattern(source: string, message?: string): this {
229
+ this._baseSchema.pattern = source
230
+ if (message) {
231
+ this._customMessages['string.pattern'] = message
232
+ }
233
+ return this
234
+ }
235
+
236
+ /**
237
+ * Custom error messages (IDslBuilder: error; BC alias: messages).
238
+ */
239
+ messages(msgs: Record<string, string>): this {
240
+ Object.assign(this._customMessages, msgs)
241
+ return this
242
+ }
243
+
244
+ /** IDslBuilder.error — alias for messages() */
245
+ error(msgs: Record<string, string>): this {
246
+ return this.messages(msgs)
247
+ }
248
+
249
+ /**
250
+ * Set field label (used in error messages).
251
+ */
252
+ label(text: string): this {
253
+ this._label = text
254
+ return this
255
+ }
256
+
257
+ /**
258
+ * Set description.
259
+ */
260
+ description(text: string): this {
261
+ this._description = text
262
+ return this
263
+ }
264
+
265
+ /**
266
+ * Set default value.
267
+ */
268
+ default(value: unknown): this {
269
+ this._baseSchema.default = value
270
+ return this
271
+ }
272
+
273
+ /**
274
+ * Set allowed enum values (IDslBuilder).
275
+ */
276
+ enum(...values: unknown[]): this {
277
+ this._baseSchema.enum = values
278
+ return this
279
+ }
280
+
281
+ /**
282
+ * Mark field as optional.
283
+ */
284
+ optional(): this {
285
+ this._required = false
286
+ this._optional = true
287
+ return this
288
+ }
289
+
290
+ /**
291
+ * Mark field as required.
292
+ */
293
+ required(): this {
294
+ this._required = true
295
+ this._optional = false
296
+ return this
297
+ }
298
+
299
+ /**
300
+ * Add a custom validator function.
301
+ */
302
+ custom(validatorFn: CustomValidatorFn): this {
303
+ if (typeof validatorFn !== 'function') {
304
+ throw new Error('[schema-dsl] Custom validator must be a function')
305
+ }
306
+ this._customValidators.push(validatorFn)
307
+ return this
308
+ }
309
+
310
+ // ==================== String Chain Methods ====================
311
+
312
+ /** String minimum length. */
313
+ min(n: number): this {
314
+ this._assertStringType('min')
315
+ this._baseSchema.minLength = n
316
+ return this
317
+ }
318
+
319
+ /** String maximum length. */
320
+ max(n: number): this {
321
+ this._assertStringType('max')
322
+ this._baseSchema.maxLength = n
323
+ return this
324
+ }
325
+
326
+ /** String exact length (→ exactLength custom keyword). */
327
+ length(n: number): this {
328
+ this._assertStringType('length')
329
+ this._baseSchema.exactLength = n
330
+ return this
331
+ }
332
+
333
+ /** String: only alphanumeric characters allowed. */
334
+ alphanum(): this {
335
+ this._assertStringType('alphanum')
336
+ this._baseSchema.alphanum = true
337
+ return this
338
+ }
339
+
340
+ /** String: no leading/trailing whitespace. */
341
+ trim(): this {
342
+ this._assertStringType('trim')
343
+ this._baseSchema.trim = true
344
+ return this
345
+ }
346
+
347
+ /** String: must be lowercase. */
348
+ lowercase(): this {
349
+ this._assertStringType('lowercase')
350
+ this._baseSchema.lowercase = true
351
+ return this
352
+ }
353
+
354
+ /** String: must be uppercase. */
355
+ uppercase(): this {
356
+ this._assertStringType('uppercase')
357
+ this._baseSchema.uppercase = true
358
+ return this
359
+ }
360
+
361
+ /** String: must be a valid JSON string. */
362
+ json(): this {
363
+ this._assertStringType('json')
364
+ this._baseSchema.jsonString = true
365
+ return this
366
+ }
367
+
368
+ /** String date format validation. */
369
+ dateFormat(fmt: string): this {
370
+ this._assertStringType('dateFormat')
371
+ this._baseSchema.dateFormat = fmt
372
+ return this
373
+ }
374
+
375
+ /** String: must be after the given date. */
376
+ after(date: string): this {
377
+ this._assertStringType('after')
378
+ this._baseSchema.dateGreater = date
379
+ return this
380
+ }
381
+
382
+ /** String: must be before the given date. */
383
+ before(date: string): this {
384
+ this._assertStringType('before')
385
+ this._baseSchema.dateLess = date
386
+ return this
387
+ }
388
+
389
+ /** v1.0.2 alias: dateGreater. */
390
+ dateGreater(date: string): this {
391
+ this._assertStringType('dateGreater')
392
+ this._baseSchema.dateGreater = date
393
+ return this
394
+ }
395
+
396
+ /** v1.0.2 alias: dateLess. */
397
+ dateLess(date: string): this {
398
+ this._assertStringType('dateLess')
399
+ this._baseSchema.dateLess = date
400
+ return this
401
+ }
402
+
403
+ /** String slug format validation. */
404
+ slug(): this {
405
+ this._assertStringType('slug')
406
+ this._baseSchema.pattern = '^[a-z0-9]+(?:-[a-z0-9]+)*$'
407
+ const existing = (this._baseSchema._customMessages as Record<string, string> | undefined) || {}
408
+ this._baseSchema._customMessages = { ...existing, pattern: 'pattern.slug' }
409
+ return this
410
+ }
411
+
412
+ /** String domain validation. */
413
+ domain(): this {
414
+ this._assertStringType('domain')
415
+ const cfg = PATTERNS.common.domain
416
+ return this._setPattern(cfg.pattern.source).messages({ pattern: cfg.key })
417
+ }
418
+
419
+ /** String IP address validation (IPv4 or IPv6). */
420
+ ip(): this {
421
+ this._assertStringType('ip')
422
+ const cfg = PATTERNS.common.ip
423
+ return this._setPattern(cfg.pattern.source).messages({ pattern: cfg.key })
424
+ }
425
+
426
+ /** String Base64 encoding validation. */
427
+ base64(): this {
428
+ this._assertStringType('base64')
429
+ const cfg = PATTERNS.common.base64
430
+ return this._setPattern(cfg.pattern.source).messages({ pattern: cfg.key })
431
+ }
432
+
433
+ /** String JWT token validation. */
434
+ jwt(): this {
435
+ this._assertStringType('jwt')
436
+ const cfg = PATTERNS.common.jwt
437
+ return this._setPattern(cfg.pattern.source).messages({ pattern: cfg.key })
438
+ }
439
+
440
+ // ==================== Identity / Pattern Chain Methods ====================
441
+
442
+ /** Phone number validation (auto-corrects number string). */
443
+ phone(country = 'cn'): this {
444
+ // Auto-correct type
445
+ if (this._baseSchema.type === 'number' || this._baseSchema.type === 'integer') {
446
+ this._baseSchema.type = 'string'
447
+ delete (this._baseSchema as Record<string, unknown>)['minimum']
448
+ delete (this._baseSchema as Record<string, unknown>)['maximum']
449
+ }
450
+ const cfg = PATTERNS.phone[country]
451
+ if (!cfg) throw new Error(`[schema-dsl] Unsupported country: ${country}`)
452
+ if (cfg.min !== undefined && !this._baseSchema.minLength) this._baseSchema.minLength = cfg.min
453
+ if (cfg.max !== undefined && !this._baseSchema.maxLength) this._baseSchema.maxLength = cfg.max
454
+ return this._setPattern(cfg.pattern.source).messages({ pattern: cfg.key })
455
+ }
456
+
457
+ /** phone() alias (BC). */
458
+ phoneNumber(country = 'cn'): this {
459
+ return this.phone(country)
460
+ }
461
+
462
+ /** National ID (idCard) validation. */
463
+ idCard(country = 'cn'): this {
464
+ const lower = country.toLowerCase()
465
+ const cfg = PATTERNS.idCard[lower]
466
+ if (!cfg) throw new Error(`[schema-dsl] Unsupported country for idCard: ${country}`)
467
+ if (cfg.min !== undefined && !this._baseSchema.minLength) this._baseSchema.minLength = cfg.min
468
+ if (cfg.max !== undefined && !this._baseSchema.maxLength) this._baseSchema.maxLength = cfg.max
469
+ return this._setPattern(cfg.pattern.source).messages({ pattern: cfg.key })
470
+ }
471
+
472
+ /** URL slug validation. */
473
+ slugChain(): this {
474
+ return this._setPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/.source).messages({ pattern: 'pattern.slug' })
475
+ }
476
+
477
+ /** Credit card number validation. */
478
+ creditCard(type = 'visa'): this {
479
+ const cfg = PATTERNS.creditCard[type.toLowerCase()]
480
+ if (!cfg) throw new Error(`[schema-dsl] Unsupported credit card type: ${type}`)
481
+ return this._setPattern(cfg.pattern.source).messages({ pattern: cfg.key })
482
+ }
483
+
484
+ /** Vehicle license plate validation. */
485
+ licensePlate(country = 'cn'): this {
486
+ const cfg = PATTERNS.licensePlate[country.toLowerCase()]
487
+ if (!cfg) throw new Error(`[schema-dsl] Unsupported country for licensePlate: ${country}`)
488
+ return this._setPattern(cfg.pattern.source).messages({ pattern: cfg.key })
489
+ }
490
+
491
+ /** Postal code validation. */
492
+ postalCode(country = 'cn'): this {
493
+ const cfg = PATTERNS.postalCode[country.toLowerCase()]
494
+ if (!cfg) throw new Error(`[schema-dsl] Unsupported country for postalCode: ${country}`)
495
+ return this._setPattern(cfg.pattern.source).messages({ pattern: cfg.key })
496
+ }
497
+
498
+ /** Passport number validation. */
499
+ passport(country = 'cn'): this {
500
+ const cfg = PATTERNS.passport[country.toLowerCase()]
501
+ if (!cfg) throw new Error(`[schema-dsl] Unsupported country for passport: ${country}`)
502
+ return this._setPattern(cfg.pattern.source).messages({ pattern: cfg.key })
503
+ }
504
+
505
+ /**
506
+ * Username validation.
507
+ * @param preset - 'short'(3-16) | 'medium'(3-32) | 'long'(3-64) | 'N-M' | object
508
+ */
509
+ username(preset: string | { minLength?: number; maxLength?: number; allowUnderscore?: boolean; allowNumber?: boolean } = 'medium'): this {
510
+ let minLength: number
511
+ let maxLength: number
512
+ let allowUnderscore = true
513
+ let allowNumber = true
514
+
515
+ if (typeof preset === 'string') {
516
+ const rangeMatch = /^(\d+)-(\d+)$/.exec(preset)
517
+ if (rangeMatch) {
518
+ minLength = parseInt(rangeMatch[1], 10)
519
+ maxLength = parseInt(rangeMatch[2], 10)
520
+ } else {
521
+ const presets: Record<string, { min: number; max: number }> = {
522
+ short: { min: 3, max: 16 },
523
+ medium: { min: 3, max: 32 },
524
+ long: { min: 3, max: 64 },
525
+ }
526
+ const p = presets[preset] ?? presets['medium']
527
+ minLength = p.min
528
+ maxLength = p.max
529
+ }
530
+ } else {
531
+ minLength = preset.minLength ?? 3
532
+ maxLength = preset.maxLength ?? 32
533
+ allowUnderscore = preset.allowUnderscore !== false
534
+ allowNumber = preset.allowNumber !== false
535
+ }
536
+
537
+ if (!this._baseSchema.minLength) this._baseSchema.minLength = minLength
538
+ if (!this._baseSchema.maxLength) this._baseSchema.maxLength = maxLength
539
+
540
+ let pat = '^[a-zA-Z]'
541
+ if (allowUnderscore && allowNumber) {
542
+ pat += '[a-zA-Z0-9_]*$'
543
+ } else if (allowNumber) {
544
+ pat += '[a-zA-Z0-9]*$'
545
+ } else {
546
+ pat += '[a-zA-Z]*$'
547
+ }
548
+
549
+ return this._setPattern(pat).messages({ pattern: 'pattern.username' })
550
+ }
551
+
552
+ /**
553
+ * Password strength validation.
554
+ * @param strength - 'weak' | 'medium' | 'strong' | 'veryStrong'
555
+ */
556
+ password(strength = 'medium'): this {
557
+ const pat = PASSWORD_PATTERNS[strength]
558
+ if (!pat) throw new Error(`[schema-dsl] Invalid password strength: ${strength}`)
559
+ if (!this._baseSchema.minLength) this._baseSchema.minLength = PASSWORD_MIN_LENGTHS[strength]
560
+ if (!this._baseSchema.maxLength) this._baseSchema.maxLength = 64
561
+ return this._setPattern(pat.source).messages({ pattern: `pattern.password.${strength}` })
562
+ }
563
+
564
+ // ==================== Number Chain Methods ====================
565
+
566
+ /** Number decimal places limit. */
567
+ precision(n: number): this {
568
+ this._assertNumberType('precision')
569
+ this._baseSchema.precision = n
570
+ return this
571
+ }
572
+
573
+ /** Number multiple-of validation (standard JSON Schema multipleOf). */
574
+ multiple(n: number): this {
575
+ this._assertNumberType('multiple')
576
+ this._baseSchema.multipleOf = n
577
+ return this
578
+ }
579
+
580
+ /** Number port validation (1–65535). */
581
+ port(): this {
582
+ this._assertNumberType('port')
583
+ this._baseSchema.port = true
584
+ return this
585
+ }
586
+
587
+ // ==================== Object Chain Methods ====================
588
+
589
+ /** Object: all defined properties are required. */
590
+ requireAll(): this {
591
+ this._assertObjectType('requireAll')
592
+ this._baseSchema.requiredAll = true
593
+ return this
594
+ }
595
+
596
+ /** Object strict mode: no additional properties allowed. */
597
+ strict(): this {
598
+ this._assertObjectType('strict')
599
+ this._baseSchema.strictSchema = true
600
+ return this
601
+ }
602
+
603
+ // ==================== Array Chain Methods ====================
604
+
605
+ /** Array: sparse arrays are not allowed. */
606
+ noSparse(): this {
607
+ this._assertArrayType('noSparse')
608
+ this._baseSchema.noSparse = true
609
+ return this
610
+ }
611
+
612
+ /** Array: must contain the specified element. */
613
+ includesRequired(items: unknown[]): this {
614
+ this._assertArrayType('includesRequired')
615
+ if (!Array.isArray(items)) {
616
+ throw new Error('[schema-dsl] includesRequired() requires an array parameter')
617
+ }
618
+ this._baseSchema.includesRequired = items
619
+ return this
620
+ }
621
+
622
+ // ==================== Output Methods ====================
623
+
624
+ /**
625
+ * Convert to a schema with schema-dsl internal fields (for use by Validator).
626
+ */
627
+ toSchema(): JSONSchema {
628
+ const schema: JSONSchema = { ...this._baseSchema }
629
+
630
+ if (this._description) {
631
+ schema.description = this._description
632
+ }
633
+
634
+ // Merge _customMessages: base type messages + user custom messages (user takes priority)
635
+ const baseCustomMsgs = (schema._customMessages as Record<string, string> | undefined) || {}
636
+ const mergedMsgs = { ...baseCustomMsgs, ...this._customMessages }
637
+ if (Object.keys(mergedMsgs).length > 0) {
638
+ schema._customMessages = mergedMsgs
639
+ } else {
640
+ delete (schema as Record<string, unknown>)['_customMessages']
641
+ }
642
+
643
+ if (this._label) {
644
+ schema._label = this._label
645
+ }
646
+
647
+ if (this._customValidators.length > 0) {
648
+ schema._customValidators = this._customValidators as unknown[]
649
+ }
650
+
651
+ if (this._whenConditions.length > 0) {
652
+ schema._whenConditions = this._whenConditions
653
+ }
654
+
655
+ // Always output _required (BC with v1: output even when false)
656
+ schema._required = this._required
657
+
658
+ return schema
659
+ }
660
+
661
+ /**
662
+ * Output a clean JSON Schema (strips all schema-dsl internal fields and custom keywords).
663
+ * Can be embedded directly in OpenAPI / standard JSON Schema documents.
664
+ */
665
+ toJsonSchema(): JSONSchema {
666
+ return TypeRegistry.toJsonSchema(this.toSchema())
667
+ }
668
+
669
+ toString(): string {
670
+ return JSON.stringify(this.toJsonSchema())
671
+ }
672
+
673
+ /**
674
+ * Validate data (BC with v1).
675
+ * @param data - data to validate
676
+ */
677
+ private _validator: ValidatorInstance | null = null
678
+
679
+ async validate(data: unknown): Promise<ValidationResult<unknown>> {
680
+ if (!this._validator) {
681
+ const { Validator } = await import('./Validator.js')
682
+ this._validator = new Validator()
683
+ }
684
+ const schema = this.toSchema()
685
+ return this._validator.validate(schema, data)
686
+ }
687
+ }