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.
- package/CHANGELOG.md +130 -113
- package/LICENSE +21 -21
- package/README.md +628 -628
- package/dist/{DslBuilder-DkLaOo9Q.d.ts → DslBuilder-BIgQOAXp.d.ts} +2 -0
- package/dist/{DslBuilder-DQDN0ZxZ.d.cts → DslBuilder-CjHTucNQ.d.cts} +2 -0
- package/dist/{Validator-hFWKGxir.d.ts → Validator-CllRdrY0.d.ts} +1 -1
- package/dist/{Validator-C7GsVQOH.d.cts → Validator-D6okG9tr.d.cts} +1 -1
- package/dist/index.cjs +75 -29
- package/dist/index.d.cts +10 -4
- package/dist/index.d.ts +10 -4
- package/dist/index.js +75 -29
- package/dist/plugins/custom-format.cjs +33 -17
- package/dist/plugins/custom-format.d.cts +1 -1
- package/dist/plugins/custom-format.d.ts +1 -1
- package/dist/plugins/custom-format.js +33 -17
- package/dist/plugins/custom-type-example.cjs +33 -17
- package/dist/plugins/custom-type-example.d.cts +1 -1
- package/dist/plugins/custom-type-example.d.ts +1 -1
- package/dist/plugins/custom-type-example.js +33 -17
- package/dist/plugins/custom-validator.cjs +0 -2
- package/dist/plugins/custom-validator.d.cts +1 -1
- package/dist/plugins/custom-validator.d.ts +1 -1
- package/dist/plugins/custom-validator.js +0 -2
- package/docs/FEATURE-INDEX.md +553 -553
- package/docs/add-custom-locale.md +496 -496
- package/docs/add-keyword.md +24 -24
- package/docs/api-reference.md +1047 -1047
- package/docs/api.md +13 -13
- package/docs/best-practices-project-structure.md +417 -417
- package/docs/best-practices.md +712 -712
- package/docs/cache-manager.md +344 -344
- package/docs/compile.md +45 -45
- package/docs/conditional-api.md +1307 -1307
- package/docs/custom-extensions-guide.md +339 -339
- package/docs/design-philosophy.md +606 -606
- package/docs/doc-index.md +324 -324
- package/docs/dsl-syntax.md +714 -714
- package/docs/dynamic-locale.md +608 -608
- package/docs/enum.md +482 -482
- package/docs/error-handling.md +1975 -1975
- package/docs/export-guide.md +501 -501
- package/docs/export-limitations.md +567 -567
- package/docs/faq.md +596 -596
- package/docs/frontend-i18n-guide.md +307 -307
- package/docs/i18n-user-guide.md +487 -487
- package/docs/i18n.md +476 -476
- package/docs/index.md +48 -48
- package/docs/json-schema-basics.md +40 -40
- package/docs/label-vs-description.md +271 -271
- package/docs/markdown-exporter.md +406 -406
- package/docs/mongodb-exporter.md +302 -302
- package/docs/multi-language.md +26 -26
- package/docs/multi-type-support.md +322 -322
- package/docs/mysql-exporter.md +280 -280
- package/docs/number-operators.md +449 -449
- package/docs/optional-marker-guide.md +326 -326
- package/docs/performance-guide.md +49 -49
- package/docs/plugin-system.md +381 -381
- package/docs/plugin-type-registration.md +34 -34
- package/docs/postgresql-exporter.md +311 -311
- package/docs/public/favicon.svg +4 -4
- package/docs/quick-start.md +435 -435
- package/docs/runtime-locale-support.md +532 -532
- package/docs/schema-helper.md +345 -345
- package/docs/schema-utils-advanced-issues.md +23 -23
- package/docs/schema-utils-best-practices.md +20 -20
- package/docs/schema-utils-chaining.md +150 -150
- package/docs/schema-utils.md +524 -524
- package/docs/security-checklist.md +20 -20
- package/docs/string-extensions.md +488 -488
- package/docs/troubleshooting.md +486 -486
- package/docs/type-converter.md +310 -310
- package/docs/type-reference.md +242 -242
- package/docs/typescript-guide.md +584 -584
- package/docs/union-type-guide.md +157 -157
- package/docs/union-types.md +284 -284
- package/docs/validate-async.md +491 -491
- package/docs/validate-batch.md +49 -49
- package/docs/validate-dsl-object-support.md +578 -578
- package/docs/validate.md +506 -506
- package/docs/validation-guide.md +502 -502
- package/docs/validator.md +39 -39
- package/package.json +131 -131
- package/plugins/custom-format.cjs +8 -8
- package/plugins/custom-type-example.cjs +8 -8
- package/plugins/custom-validator.cjs +8 -8
- package/src/adapters/DslAdapter.ts +111 -111
- package/src/adapters/index.ts +1 -1
- package/src/config/constants.ts +83 -83
- package/src/config/index.ts +2 -2
- package/src/config/patterns.ts +77 -77
- package/src/core/CacheManager.ts +169 -159
- package/src/core/ConditionalBuilder.ts +382 -382
- package/src/core/ConditionalRuntime.ts +27 -27
- package/src/core/ConditionalValidator.ts +254 -254
- package/src/core/DslBuilder.ts +687 -677
- package/src/core/ErrorCodes.ts +38 -38
- package/src/core/ErrorFormatter.ts +271 -271
- package/src/core/JSONSchemaCore.ts +65 -65
- package/src/core/Locale.ts +187 -187
- package/src/core/MessageTemplate.ts +42 -42
- package/src/core/ObjectDslBuilder.ts +64 -64
- package/src/core/PluginManager.ts +326 -326
- package/src/core/StringExtensions.ts +140 -140
- package/src/core/TemplateEngine.ts +44 -44
- package/src/core/Validator.ts +448 -448
- package/src/errors/I18nError.ts +159 -159
- package/src/errors/ValidationError.ts +105 -105
- package/src/exporters/BaseExporter.ts +60 -60
- package/src/exporters/MarkdownExporter.ts +305 -305
- package/src/exporters/MongoDBExporter.ts +126 -126
- package/src/exporters/MySQLExporter.ts +156 -155
- package/src/exporters/PostgreSQLExporter.ts +222 -222
- package/src/exporters/index.ts +18 -18
- package/src/index.ts +651 -633
- package/src/locales/en-US.ts +160 -160
- package/src/locales/es-ES.ts +160 -160
- package/src/locales/fr-FR.ts +160 -160
- package/src/locales/index.ts +103 -103
- package/src/locales/ja-JP.ts +160 -160
- package/src/locales/types.ts +156 -156
- package/src/locales/zh-CN.ts +160 -160
- package/src/parser/ConstraintParser.ts +101 -101
- package/src/parser/DslParser.ts +470 -470
- package/src/parser/SchemaCompiler.ts +66 -66
- package/src/parser/TypeRegistry.ts +250 -250
- package/src/parser/index.ts +6 -6
- package/src/plugins/custom-format.ts +124 -126
- package/src/plugins/custom-type-example.ts +106 -108
- package/src/plugins/custom-validator.ts +138 -140
- package/src/types/conditional.ts +28 -28
- package/src/types/config.ts +59 -59
- package/src/types/dsl.ts +131 -131
- package/src/types/error.ts +60 -60
- package/src/types/index.ts +17 -17
- package/src/types/infer.ts +127 -127
- package/src/types/plugin.ts +58 -58
- package/src/types/safe-regex.d.ts +9 -9
- package/src/types/schema.ts +66 -66
- package/src/types/validate.ts +71 -71
- package/src/utils/SchemaHelper.ts +196 -196
- package/src/utils/SchemaUtils.ts +365 -346
- package/src/utils/TypeConverter.ts +215 -215
- package/src/utils/index.ts +10 -10
- package/src/validators/CustomKeywords.ts +477 -477
package/src/parser/DslParser.ts
CHANGED
|
@@ -1,470 +1,470 @@
|
|
|
1
|
-
import type { JSONSchema } from '../types/schema.js'
|
|
2
|
-
import type { DslDefinition } from '../types/dsl.js'
|
|
3
|
-
import { TypeRegistry } from './TypeRegistry.js'
|
|
4
|
-
import { ConstraintParser } from './ConstraintParser.js'
|
|
5
|
-
import { SchemaCompiler } from './SchemaCompiler.js'
|
|
6
|
-
import { PATTERNS } from '../config/patterns.js'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* DslParser — unified entry point for parsing DSL strings and object definitions
|
|
10
|
-
*
|
|
11
|
-
* Replaces the dual implementations of DslBuilder._parseTypeString() and DslAdapter._parseType() from v1.
|
|
12
|
-
* All parsing flows through a single pipeline:
|
|
13
|
-
* parseString() → TypeRegistry.resolve() → ConstraintParser.parse() → SchemaCompiler.compile()
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** Set of standard JSON Schema types used to distinguish native JSON Schema objects from DSL definition objects. */
|
|
17
|
-
const JSON_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'])
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Determine whether an object is a raw JSON Schema (rather than a DSL definition object).
|
|
21
|
-
*
|
|
22
|
-
* Criteria:
|
|
23
|
-
* 1. The `type` field is a valid JSON Schema type string, or
|
|
24
|
-
* 2. The object contains JSON Schema keywords such as anyOf / oneOf / allOf / $ref
|
|
25
|
-
*
|
|
26
|
-
* This allows distinguishing:
|
|
27
|
-
* - `{ type: 'object', properties: {...} }` → raw JSON Schema ✅
|
|
28
|
-
* - `{ street: 'string!', city: 'string!' }` → DSL definition ✅
|
|
29
|
-
*/
|
|
30
|
-
function _isRawJsonSchema(obj: Record<string, unknown>): boolean {
|
|
31
|
-
if (typeof obj['type'] === 'string' && JSON_SCHEMA_TYPES.has(obj['type'] as string)) return true
|
|
32
|
-
if ('anyOf' in obj || 'oneOf' in obj || 'allOf' in obj || '$ref' in obj) return true
|
|
33
|
-
return false
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function _cleanRequiredMarks(schema: unknown): void {
|
|
37
|
-
if (!schema || typeof schema !== 'object') return
|
|
38
|
-
delete (schema as Record<string, unknown>)['_required']
|
|
39
|
-
const obj = schema as JSONSchema
|
|
40
|
-
if (obj.properties) {
|
|
41
|
-
for (const prop of Object.values(obj.properties)) _cleanRequiredMarks(prop)
|
|
42
|
-
}
|
|
43
|
-
if (obj.items) _cleanRequiredMarks(obj.items)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function _copyHiddenSchemaProperties(source: object, target: object): void {
|
|
47
|
-
for (const symbol of Object.getOwnPropertySymbols(source)) {
|
|
48
|
-
const descriptor = Object.getOwnPropertyDescriptor(source, symbol)
|
|
49
|
-
if (descriptor) Object.defineProperty(target, symbol, descriptor)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function _resolveDsl(value: unknown): JSONSchema {
|
|
54
|
-
if (value === null || value === undefined) return {}
|
|
55
|
-
if (typeof value === 'string') return DslParser.parseString(value)
|
|
56
|
-
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
57
|
-
const obj = value as Record<string, unknown>
|
|
58
|
-
if (typeof obj['toSchema'] === 'function') return (obj['toSchema'] as () => JSONSchema)()
|
|
59
|
-
if (_isRawJsonSchema(obj)) return value as JSONSchema
|
|
60
|
-
return DslParser.parseObject(value as DslDefinition)
|
|
61
|
-
}
|
|
62
|
-
return value as JSONSchema
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function _schemaForTarget(targetField: string, dslValue: unknown): JSONSchema {
|
|
66
|
-
const s = _resolveDsl(dslValue)
|
|
67
|
-
const isRequired = s._required
|
|
68
|
-
_cleanRequiredMarks(s)
|
|
69
|
-
const result: JSONSchema = { properties: { [targetField]: s } }
|
|
70
|
-
if (isRequired) result.required = [targetField]
|
|
71
|
-
return result
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function _buildMatchSchema(conditionField: string, targetField: string, map: Record<string, unknown>): JSONSchema {
|
|
75
|
-
const entries = Object.entries(map).filter(([k]) => k !== '_default')
|
|
76
|
-
const defaultDsl = map['_default']
|
|
77
|
-
|
|
78
|
-
const build = (index: number): JSONSchema => {
|
|
79
|
-
if (index >= entries.length) {
|
|
80
|
-
if (defaultDsl === null || defaultDsl === undefined) return {}
|
|
81
|
-
const defaultObj = defaultDsl as Record<string, unknown>
|
|
82
|
-
if (defaultObj && defaultObj['_isMatch']) {
|
|
83
|
-
return _buildMatchSchema(String(defaultObj['field']), targetField, defaultObj['map'] as Record<string, unknown>)
|
|
84
|
-
}
|
|
85
|
-
if (defaultObj && defaultObj['_isIf']) {
|
|
86
|
-
return _buildIfSchema(String(defaultObj['condition']), targetField, defaultObj['then'], defaultObj['else'])
|
|
87
|
-
}
|
|
88
|
-
return _schemaForTarget(targetField, defaultDsl)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const [val, dslValue] = entries[index]
|
|
92
|
-
if (dslValue === null || dslValue === undefined) return build(index + 1)
|
|
93
|
-
|
|
94
|
-
const branchObj = dslValue as Record<string, unknown>
|
|
95
|
-
let thenSchema: JSONSchema
|
|
96
|
-
if (branchObj && branchObj['_isMatch']) {
|
|
97
|
-
thenSchema = _buildMatchSchema(String(branchObj['field']), targetField, branchObj['map'] as Record<string, unknown>)
|
|
98
|
-
} else if (branchObj && branchObj['_isIf']) {
|
|
99
|
-
thenSchema = _buildIfSchema(String(branchObj['condition']), targetField, branchObj['then'], branchObj['else'])
|
|
100
|
-
} else {
|
|
101
|
-
thenSchema = _schemaForTarget(targetField, dslValue)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
if: { properties: { [conditionField]: { const: val } } },
|
|
106
|
-
then: thenSchema,
|
|
107
|
-
else: build(index + 1),
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return build(0)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function _buildIfSchema(conditionField: string, targetField: string, thenDsl: unknown, elseDsl: unknown): JSONSchema {
|
|
115
|
-
const thenObj = thenDsl as Record<string, unknown>
|
|
116
|
-
const elseObj = elseDsl as Record<string, unknown>
|
|
117
|
-
|
|
118
|
-
let thenResult: JSONSchema
|
|
119
|
-
if (thenObj && thenObj['_isMatch']) {
|
|
120
|
-
thenResult = _buildMatchSchema(String(thenObj['field']), targetField, thenObj['map'] as Record<string, unknown>)
|
|
121
|
-
} else if (thenObj && thenObj['_isIf']) {
|
|
122
|
-
thenResult = _buildIfSchema(String(thenObj['condition']), targetField, thenObj['then'], thenObj['else'])
|
|
123
|
-
} else {
|
|
124
|
-
thenResult = _schemaForTarget(targetField, thenDsl)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
let elseResult: JSONSchema = {}
|
|
128
|
-
if (elseDsl !== null && elseDsl !== undefined) {
|
|
129
|
-
if (elseObj && elseObj['_isMatch']) {
|
|
130
|
-
elseResult = _buildMatchSchema(String(elseObj['field']), targetField, elseObj['map'] as Record<string, unknown>)
|
|
131
|
-
} else if (elseObj && elseObj['_isIf']) {
|
|
132
|
-
elseResult = _buildIfSchema(String(elseObj['condition']), targetField, elseObj['then'], elseObj['else'])
|
|
133
|
-
} else {
|
|
134
|
-
elseResult = _schemaForTarget(targetField, elseDsl)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return {
|
|
139
|
-
if: { properties: { [conditionField]: { const: true } } },
|
|
140
|
-
then: thenResult,
|
|
141
|
-
else: elseResult,
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export const DslParser = {
|
|
146
|
-
/**
|
|
147
|
-
* Parse a DSL string → JSONSchema
|
|
148
|
-
*
|
|
149
|
-
* Supported formats:
|
|
150
|
-
* - 'string' → { type: 'string' }
|
|
151
|
-
* - 'string!' → { type: 'string', _required: true }
|
|
152
|
-
* - 'string:6' → { type: 'string', exactLength: 6 } (DA-03 fix)
|
|
153
|
-
* - 'string:3-32' → { type: 'string', minLength: 3, maxLength: 32 }
|
|
154
|
-
* - 'number:0-100' → { type: 'number', minimum: 0, maximum: 100 }
|
|
155
|
-
* - 'number:-100-0' → { type: 'number', minimum: -100, maximum: 0 } (DB-03 fix)
|
|
156
|
-
* - 'enum:a,b,c' → { type: 'string', enum: ['a','b','c'] }
|
|
157
|
-
* - 'a|b|c' → { type: 'string', enum: ['a','b','c'] }
|
|
158
|
-
* - 'array!1-10' → { type: 'array', minItems:1, maxItems:10, _required:true }
|
|
159
|
-
*/
|
|
160
|
-
parseString(dslStr: string): JSONSchema {
|
|
161
|
-
if (!dslStr || typeof dslStr !== 'string') {
|
|
162
|
-
return { type: 'string' }
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
let s = dslStr.trim()
|
|
166
|
-
let required = false
|
|
167
|
-
|
|
168
|
-
// ========== Pre-processing 1: array!N-M special syntax (v1 compat) ==========
|
|
169
|
-
// 'array!1-10' → equivalent to 'array:1-10' + required=true
|
|
170
|
-
const arrayBangMatch = /^array!([\d-]+)$/.exec(s)
|
|
171
|
-
if (arrayBangMatch) {
|
|
172
|
-
s = `array:${arrayBangMatch[1]}`
|
|
173
|
-
required = true
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ========== Pre-processing 2: trailing '!' / '?' → required/optional marker, strip ==========
|
|
177
|
-
if (s.endsWith('!')) {
|
|
178
|
-
required = true
|
|
179
|
-
s = s.slice(0, -1)
|
|
180
|
-
} else if (s.endsWith('?')) {
|
|
181
|
-
s = s.slice(0, -1)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// ========== Special case: pipe enum 'a|b|c' (no colon — entire segment is enum) ==========
|
|
185
|
-
if (s.includes('|') && !s.includes(':')) {
|
|
186
|
-
const rawValues = s.split('|').map(v => v.trim())
|
|
187
|
-
// Auto-detect type from values
|
|
188
|
-
if (rawValues.every(v => v === 'true' || v === 'false')) {
|
|
189
|
-
// Boolean enum
|
|
190
|
-
return {
|
|
191
|
-
type: 'boolean',
|
|
192
|
-
enum: rawValues.map(v => v === 'true'),
|
|
193
|
-
...(required ? { _required: true } : {}),
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
const numericValues = rawValues.map(v => Number(v))
|
|
197
|
-
if (rawValues.every((v, i) => v !== '' && !isNaN(numericValues[i]))) {
|
|
198
|
-
// Numeric enum — always use 'number' type for v1 compat
|
|
199
|
-
return {
|
|
200
|
-
type: 'number',
|
|
201
|
-
enum: numericValues,
|
|
202
|
-
...(required ? { _required: true } : {}),
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
// String enum (default)
|
|
206
|
-
return {
|
|
207
|
-
type: 'string',
|
|
208
|
-
enum: rawValues,
|
|
209
|
-
...(required ? { _required: true } : {}),
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ========== Special case: enum: prefix ==========
|
|
214
|
-
// 'enum:a,b,c' → { type:'string', enum:['a','b','c'] }
|
|
215
|
-
// 'enum:number:1,2,3' → { type:'number', enum:[1,2,3] }
|
|
216
|
-
if (s.startsWith('enum:')) {
|
|
217
|
-
return DslParser._parseEnumSyntax(s, required)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ========== Special case: types: union type prefix (v1 compat) ==========
|
|
221
|
-
// 'types:string|number' → oneOf: [{ type:'string' }, { type:'number' }]
|
|
222
|
-
// 'types:string:3-10|number:0-100' → oneOf with constraints
|
|
223
|
-
// 'types:email|phone' → oneOf with format types
|
|
224
|
-
if (s.startsWith('types:')) {
|
|
225
|
-
return DslParser._parseUnionTypes(s.slice(6), required)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ========== Special case: array<TYPE> syntax ==========
|
|
229
|
-
// 'array<string>' → { type:'array', items:{ type:'string' } }
|
|
230
|
-
// 'array<enum:public|private>' → { type:'array', items:{ type:'string', enum:[...] } }
|
|
231
|
-
// 'array:1-5<string:1-20>' → { type:'array', minItems:1, maxItems:5, items:{ type:'string', minLength:1, maxLength:20 } }
|
|
232
|
-
const arrayAngleWithConstraintMatch = /^array:([^<]+)<(.+)>$/.exec(s)
|
|
233
|
-
if (arrayAngleWithConstraintMatch) {
|
|
234
|
-
const arrayConstraint = ConstraintParser.parse(arrayAngleWithConstraintMatch[1], 'array')
|
|
235
|
-
const itemSchema = DslParser.parseString(arrayAngleWithConstraintMatch[2])
|
|
236
|
-
return {
|
|
237
|
-
type: 'array',
|
|
238
|
-
...arrayConstraint,
|
|
239
|
-
items: itemSchema,
|
|
240
|
-
...(required ? { _required: true } : {}),
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
const arrayAngleMatch = /^array<(.+)>$/.exec(s)
|
|
244
|
-
if (arrayAngleMatch) {
|
|
245
|
-
const itemSchema = DslParser.parseString(arrayAngleMatch[1])
|
|
246
|
-
return {
|
|
247
|
-
type: 'array',
|
|
248
|
-
items: itemSchema,
|
|
249
|
-
...(required ? { _required: true } : {}),
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ========== Main parsing: typeName[:constraint] ==========
|
|
254
|
-
const colonIdx = s.indexOf(':')
|
|
255
|
-
let typeName: string
|
|
256
|
-
let constraintStr: string
|
|
257
|
-
|
|
258
|
-
if (colonIdx === -1) {
|
|
259
|
-
typeName = s
|
|
260
|
-
constraintStr = ''
|
|
261
|
-
} else {
|
|
262
|
-
typeName = s.slice(0, colonIdx)
|
|
263
|
-
constraintStr = s.slice(colonIdx + 1)
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ========== Special case: pattern types (phone/idCard/creditCard/licensePlate/postalCode/passport) ==========
|
|
267
|
-
const PATTERN_TYPES = ['phone', 'idCard', 'creditCard', 'licensePlate', 'postalCode', 'passport'] as const
|
|
268
|
-
if (PATTERN_TYPES.includes(typeName as typeof PATTERN_TYPES[number])) {
|
|
269
|
-
const patternGroup = PATTERNS[typeName as keyof typeof PATTERNS] as Record<string, { pattern: RegExp; min?: number; max?: number; key: string }>
|
|
270
|
-
if (patternGroup) {
|
|
271
|
-
const arg = constraintStr || (typeName === 'creditCard' ? 'visa' : 'cn')
|
|
272
|
-
const cfg = patternGroup[arg.toLowerCase()]
|
|
273
|
-
if (cfg) {
|
|
274
|
-
return {
|
|
275
|
-
type: 'string',
|
|
276
|
-
pattern: cfg.pattern.source,
|
|
277
|
-
...(cfg.min !== undefined ? { minLength: cfg.min } : {}),
|
|
278
|
-
...(cfg.max !== undefined ? { maxLength: cfg.max } : {}),
|
|
279
|
-
_customMessages: { pattern: cfg.key },
|
|
280
|
-
...(required ? { _required: true } : {}),
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
throw new Error(`[schema-dsl] Unsupported country/variant "${arg}" for type "${typeName}"`)
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// TypeRegistry resolution
|
|
288
|
-
const typeDef = TypeRegistry.resolve(typeName)
|
|
289
|
-
|
|
290
|
-
// ConstraintParser parse — use resolved base type (e.g., 'string' for 'alphanum')
|
|
291
|
-
const resolvedBaseType = (typeDef.baseSchema.type as string) ?? typeName
|
|
292
|
-
const constraints = ConstraintParser.parse(constraintStr, resolvedBaseType)
|
|
293
|
-
|
|
294
|
-
// SchemaCompiler assembly
|
|
295
|
-
const schema = SchemaCompiler.compile(typeDef, constraints, {
|
|
296
|
-
required,
|
|
297
|
-
})
|
|
298
|
-
|
|
299
|
-
return schema
|
|
300
|
-
},
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Parse an object DSL definition → JSONSchema (type:object + properties + required[])
|
|
304
|
-
*/
|
|
305
|
-
parseObject(dslObj: DslDefinition): JSONSchema {
|
|
306
|
-
const schema: JSONSchema = {
|
|
307
|
-
type: 'object',
|
|
308
|
-
properties: {},
|
|
309
|
-
required: [],
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
for (const [rawKey, value] of Object.entries(dslObj)) {
|
|
313
|
-
let fieldKey = rawKey
|
|
314
|
-
let isKeyRequired = false
|
|
315
|
-
|
|
316
|
-
// key! suffix marks this field as required
|
|
317
|
-
if (rawKey.endsWith('!')) {
|
|
318
|
-
fieldKey = rawKey.slice(0, -1)
|
|
319
|
-
isKeyRequired = true
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
let fieldSchema: JSONSchema
|
|
323
|
-
|
|
324
|
-
if (typeof value === 'string') {
|
|
325
|
-
fieldSchema = DslParser.parseString(value)
|
|
326
|
-
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
327
|
-
const obj = value as Record<string, unknown>
|
|
328
|
-
if (obj['_isMatch']) {
|
|
329
|
-
if (!schema.allOf) schema.allOf = []
|
|
330
|
-
schema.allOf.push(_buildMatchSchema(String(obj['field']), fieldKey, obj['map'] as Record<string, unknown>))
|
|
331
|
-
fieldSchema = { description: `Depends on ${String(obj['field'])}` }
|
|
332
|
-
} else if (obj['_isIf']) {
|
|
333
|
-
if (!schema.allOf) schema.allOf = []
|
|
334
|
-
schema.allOf.push(_buildIfSchema(String(obj['condition']), fieldKey, obj['then'], obj['else']))
|
|
335
|
-
fieldSchema = { description: `Conditional field based on ${String(obj['condition'])}` }
|
|
336
|
-
} else if (typeof obj['toSchema'] === 'function') {
|
|
337
|
-
// DslBuilder instance or ConditionalBuilder (has toSchema method)
|
|
338
|
-
fieldSchema = (obj['toSchema'] as () => JSONSchema)()
|
|
339
|
-
} else if (_isRawJsonSchema(obj)) {
|
|
340
|
-
// Raw JSON Schema object (e.g., { type: 'object', properties: {...} }) — pass through as-is
|
|
341
|
-
fieldSchema = value as JSONSchema
|
|
342
|
-
} else {
|
|
343
|
-
// Nested DslDefinition (e.g., { street: 'string!', city: 'string!' })
|
|
344
|
-
fieldSchema = DslParser.parseObject(value as DslDefinition)
|
|
345
|
-
}
|
|
346
|
-
} else {
|
|
347
|
-
// Pass through as-is (compatible with direct schema fragment input)
|
|
348
|
-
fieldSchema = value as JSONSchema
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Apply required flag: key! takes priority over the field's internal _required marker
|
|
352
|
-
if (isKeyRequired) {
|
|
353
|
-
; (schema.required as string[]).push(fieldKey)
|
|
354
|
-
} else if (fieldSchema._required) {
|
|
355
|
-
; (schema.required as string[]).push(fieldKey)
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Strip the internal _required marker
|
|
359
|
-
const { _required: _r, ...cleanSchema } = fieldSchema as JSONSchema & { _required?: boolean }
|
|
360
|
-
void _r
|
|
361
|
-
_copyHiddenSchemaProperties(fieldSchema as object, cleanSchema as object)
|
|
362
|
-
_cleanRequiredMarks(cleanSchema)
|
|
363
|
-
|
|
364
|
-
; (schema.properties as Record<string, JSONSchema>)[fieldKey] = cleanSchema
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if ((schema.required as string[]).length === 0) {
|
|
368
|
-
delete schema.required
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return schema
|
|
372
|
-
},
|
|
373
|
-
|
|
374
|
-
// --------------- Private helpers ---------------
|
|
375
|
-
|
|
376
|
-
/** Parse enum: prefix syntax */
|
|
377
|
-
_parseEnumSyntax(s: string, required: boolean): JSONSchema {
|
|
378
|
-
// Strip the 'enum:' prefix
|
|
379
|
-
const rest = s.slice('enum:'.length)
|
|
380
|
-
|
|
381
|
-
// Check for a type prefix: 'enum:number:1|2|3' or 'enum:number:1,2,3'
|
|
382
|
-
const typedEnumMatch = /^(string|number|integer|boolean):(.+)$/.exec(rest)
|
|
383
|
-
if (typedEnumMatch) {
|
|
384
|
-
const enumType = typedEnumMatch[1] as 'string' | 'number' | 'integer' | 'boolean'
|
|
385
|
-
const rawStr = typedEnumMatch[2]
|
|
386
|
-
const rawValues = (rawStr.includes('|') ? rawStr.split('|') : rawStr.split(',')).map(v => v.trim())
|
|
387
|
-
const values = DslParser._coerceEnumValues(rawValues, enumType)
|
|
388
|
-
return {
|
|
389
|
-
type: enumType,
|
|
390
|
-
enum: values,
|
|
391
|
-
...(required ? { _required: true } : {}),
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// No type prefix: default to string, supporting both '|' and ',' as separators
|
|
396
|
-
return {
|
|
397
|
-
type: 'string',
|
|
398
|
-
enum: (rest.includes('|') ? rest.split('|') : rest.split(',')).map(v => v.trim()),
|
|
399
|
-
...(required ? { _required: true } : {}),
|
|
400
|
-
}
|
|
401
|
-
},
|
|
402
|
-
|
|
403
|
-
/** Convert a string array to enum values of the specified type */
|
|
404
|
-
_coerceEnumValues(
|
|
405
|
-
values: string[],
|
|
406
|
-
type: 'string' | 'number' | 'integer' | 'boolean'
|
|
407
|
-
): (string | number | boolean)[] {
|
|
408
|
-
if (type === 'number' || type === 'integer') {
|
|
409
|
-
return values.map(v => {
|
|
410
|
-
const n = parseFloat(v)
|
|
411
|
-
if (isNaN(n)) throw new Error(`[schema-dsl] Invalid number enum value: "${v}"`)
|
|
412
|
-
return n
|
|
413
|
-
})
|
|
414
|
-
}
|
|
415
|
-
if (type === 'boolean') {
|
|
416
|
-
return values.map(v => {
|
|
417
|
-
if (v !== 'true' && v !== 'false')
|
|
418
|
-
throw new Error(`[schema-dsl] Invalid boolean enum value: "${v}"`)
|
|
419
|
-
return v === 'true'
|
|
420
|
-
})
|
|
421
|
-
}
|
|
422
|
-
return values
|
|
423
|
-
},
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Parse types: union type syntax (v1 compatible)
|
|
427
|
-
*
|
|
428
|
-
* Splits 'string|number' into a oneOf array, parsing each segment independently.
|
|
429
|
-
* Uses smart splitting: 'string:3-10|number:0-100' → ['string:3-10', 'number:0-100']
|
|
430
|
-
* When only a single type is present, emits a plain schema instead of a oneOf wrapper.
|
|
431
|
-
*/
|
|
432
|
-
_parseUnionTypes(typesStr: string, required: boolean): JSONSchema {
|
|
433
|
-
// Smart split by | that is a type separator (not inside constraints)
|
|
434
|
-
const segments: string[] = []
|
|
435
|
-
let current = ''
|
|
436
|
-
let depth = 0
|
|
437
|
-
|
|
438
|
-
for (let i = 0; i < typesStr.length; i++) {
|
|
439
|
-
const ch = typesStr[i]
|
|
440
|
-
if (ch === '<') { depth++; current += ch }
|
|
441
|
-
else if (ch === '>') { depth--; current += ch }
|
|
442
|
-
else if (ch === '|' && depth === 0) {
|
|
443
|
-
if (current.trim()) segments.push(current.trim())
|
|
444
|
-
current = ''
|
|
445
|
-
} else {
|
|
446
|
-
current += ch
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
if (current.trim()) segments.push(current.trim())
|
|
450
|
-
|
|
451
|
-
if (segments.length === 0) {
|
|
452
|
-
throw new Error('[schema-dsl] types: requires at least one type')
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Single type → optimize to direct schema (no oneOf)
|
|
456
|
-
if (segments.length === 1) {
|
|
457
|
-
const schema = DslParser.parseString(segments[0])
|
|
458
|
-
if (required) schema._required = true
|
|
459
|
-
return schema
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Multiple types → oneOf
|
|
463
|
-
const oneOf = segments.map(seg => DslParser.parseString(seg))
|
|
464
|
-
|
|
465
|
-
return {
|
|
466
|
-
oneOf,
|
|
467
|
-
...(required ? { _required: true } : {}),
|
|
468
|
-
} as JSONSchema
|
|
469
|
-
},
|
|
470
|
-
}
|
|
1
|
+
import type { JSONSchema } from '../types/schema.js'
|
|
2
|
+
import type { DslDefinition } from '../types/dsl.js'
|
|
3
|
+
import { TypeRegistry } from './TypeRegistry.js'
|
|
4
|
+
import { ConstraintParser } from './ConstraintParser.js'
|
|
5
|
+
import { SchemaCompiler } from './SchemaCompiler.js'
|
|
6
|
+
import { PATTERNS } from '../config/patterns.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* DslParser — unified entry point for parsing DSL strings and object definitions
|
|
10
|
+
*
|
|
11
|
+
* Replaces the dual implementations of DslBuilder._parseTypeString() and DslAdapter._parseType() from v1.
|
|
12
|
+
* All parsing flows through a single pipeline:
|
|
13
|
+
* parseString() → TypeRegistry.resolve() → ConstraintParser.parse() → SchemaCompiler.compile()
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Set of standard JSON Schema types used to distinguish native JSON Schema objects from DSL definition objects. */
|
|
17
|
+
const JSON_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'])
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Determine whether an object is a raw JSON Schema (rather than a DSL definition object).
|
|
21
|
+
*
|
|
22
|
+
* Criteria:
|
|
23
|
+
* 1. The `type` field is a valid JSON Schema type string, or
|
|
24
|
+
* 2. The object contains JSON Schema keywords such as anyOf / oneOf / allOf / $ref
|
|
25
|
+
*
|
|
26
|
+
* This allows distinguishing:
|
|
27
|
+
* - `{ type: 'object', properties: {...} }` → raw JSON Schema ✅
|
|
28
|
+
* - `{ street: 'string!', city: 'string!' }` → DSL definition ✅
|
|
29
|
+
*/
|
|
30
|
+
function _isRawJsonSchema(obj: Record<string, unknown>): boolean {
|
|
31
|
+
if (typeof obj['type'] === 'string' && JSON_SCHEMA_TYPES.has(obj['type'] as string)) return true
|
|
32
|
+
if ('anyOf' in obj || 'oneOf' in obj || 'allOf' in obj || '$ref' in obj) return true
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _cleanRequiredMarks(schema: unknown): void {
|
|
37
|
+
if (!schema || typeof schema !== 'object') return
|
|
38
|
+
delete (schema as Record<string, unknown>)['_required']
|
|
39
|
+
const obj = schema as JSONSchema
|
|
40
|
+
if (obj.properties) {
|
|
41
|
+
for (const prop of Object.values(obj.properties)) _cleanRequiredMarks(prop)
|
|
42
|
+
}
|
|
43
|
+
if (obj.items) _cleanRequiredMarks(obj.items)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _copyHiddenSchemaProperties(source: object, target: object): void {
|
|
47
|
+
for (const symbol of Object.getOwnPropertySymbols(source)) {
|
|
48
|
+
const descriptor = Object.getOwnPropertyDescriptor(source, symbol)
|
|
49
|
+
if (descriptor) Object.defineProperty(target, symbol, descriptor)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _resolveDsl(value: unknown): JSONSchema {
|
|
54
|
+
if (value === null || value === undefined) return {}
|
|
55
|
+
if (typeof value === 'string') return DslParser.parseString(value)
|
|
56
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
57
|
+
const obj = value as Record<string, unknown>
|
|
58
|
+
if (typeof obj['toSchema'] === 'function') return (obj['toSchema'] as () => JSONSchema)()
|
|
59
|
+
if (_isRawJsonSchema(obj)) return value as JSONSchema
|
|
60
|
+
return DslParser.parseObject(value as DslDefinition)
|
|
61
|
+
}
|
|
62
|
+
return value as JSONSchema
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _schemaForTarget(targetField: string, dslValue: unknown): JSONSchema {
|
|
66
|
+
const s = _resolveDsl(dslValue)
|
|
67
|
+
const isRequired = s._required
|
|
68
|
+
_cleanRequiredMarks(s)
|
|
69
|
+
const result: JSONSchema = { properties: { [targetField]: s } }
|
|
70
|
+
if (isRequired) result.required = [targetField]
|
|
71
|
+
return result
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _buildMatchSchema(conditionField: string, targetField: string, map: Record<string, unknown>): JSONSchema {
|
|
75
|
+
const entries = Object.entries(map).filter(([k]) => k !== '_default')
|
|
76
|
+
const defaultDsl = map['_default']
|
|
77
|
+
|
|
78
|
+
const build = (index: number): JSONSchema => {
|
|
79
|
+
if (index >= entries.length) {
|
|
80
|
+
if (defaultDsl === null || defaultDsl === undefined) return {}
|
|
81
|
+
const defaultObj = defaultDsl as Record<string, unknown>
|
|
82
|
+
if (defaultObj && defaultObj['_isMatch']) {
|
|
83
|
+
return _buildMatchSchema(String(defaultObj['field']), targetField, defaultObj['map'] as Record<string, unknown>)
|
|
84
|
+
}
|
|
85
|
+
if (defaultObj && defaultObj['_isIf']) {
|
|
86
|
+
return _buildIfSchema(String(defaultObj['condition']), targetField, defaultObj['then'], defaultObj['else'])
|
|
87
|
+
}
|
|
88
|
+
return _schemaForTarget(targetField, defaultDsl)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const [val, dslValue] = entries[index]
|
|
92
|
+
if (dslValue === null || dslValue === undefined) return build(index + 1)
|
|
93
|
+
|
|
94
|
+
const branchObj = dslValue as Record<string, unknown>
|
|
95
|
+
let thenSchema: JSONSchema
|
|
96
|
+
if (branchObj && branchObj['_isMatch']) {
|
|
97
|
+
thenSchema = _buildMatchSchema(String(branchObj['field']), targetField, branchObj['map'] as Record<string, unknown>)
|
|
98
|
+
} else if (branchObj && branchObj['_isIf']) {
|
|
99
|
+
thenSchema = _buildIfSchema(String(branchObj['condition']), targetField, branchObj['then'], branchObj['else'])
|
|
100
|
+
} else {
|
|
101
|
+
thenSchema = _schemaForTarget(targetField, dslValue)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
if: { properties: { [conditionField]: { const: val } } },
|
|
106
|
+
then: thenSchema,
|
|
107
|
+
else: build(index + 1),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return build(0)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _buildIfSchema(conditionField: string, targetField: string, thenDsl: unknown, elseDsl: unknown): JSONSchema {
|
|
115
|
+
const thenObj = thenDsl as Record<string, unknown>
|
|
116
|
+
const elseObj = elseDsl as Record<string, unknown>
|
|
117
|
+
|
|
118
|
+
let thenResult: JSONSchema
|
|
119
|
+
if (thenObj && thenObj['_isMatch']) {
|
|
120
|
+
thenResult = _buildMatchSchema(String(thenObj['field']), targetField, thenObj['map'] as Record<string, unknown>)
|
|
121
|
+
} else if (thenObj && thenObj['_isIf']) {
|
|
122
|
+
thenResult = _buildIfSchema(String(thenObj['condition']), targetField, thenObj['then'], thenObj['else'])
|
|
123
|
+
} else {
|
|
124
|
+
thenResult = _schemaForTarget(targetField, thenDsl)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let elseResult: JSONSchema = {}
|
|
128
|
+
if (elseDsl !== null && elseDsl !== undefined) {
|
|
129
|
+
if (elseObj && elseObj['_isMatch']) {
|
|
130
|
+
elseResult = _buildMatchSchema(String(elseObj['field']), targetField, elseObj['map'] as Record<string, unknown>)
|
|
131
|
+
} else if (elseObj && elseObj['_isIf']) {
|
|
132
|
+
elseResult = _buildIfSchema(String(elseObj['condition']), targetField, elseObj['then'], elseObj['else'])
|
|
133
|
+
} else {
|
|
134
|
+
elseResult = _schemaForTarget(targetField, elseDsl)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
if: { properties: { [conditionField]: { const: true } } },
|
|
140
|
+
then: thenResult,
|
|
141
|
+
else: elseResult,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const DslParser = {
|
|
146
|
+
/**
|
|
147
|
+
* Parse a DSL string → JSONSchema
|
|
148
|
+
*
|
|
149
|
+
* Supported formats:
|
|
150
|
+
* - 'string' → { type: 'string' }
|
|
151
|
+
* - 'string!' → { type: 'string', _required: true }
|
|
152
|
+
* - 'string:6' → { type: 'string', exactLength: 6 } (DA-03 fix)
|
|
153
|
+
* - 'string:3-32' → { type: 'string', minLength: 3, maxLength: 32 }
|
|
154
|
+
* - 'number:0-100' → { type: 'number', minimum: 0, maximum: 100 }
|
|
155
|
+
* - 'number:-100-0' → { type: 'number', minimum: -100, maximum: 0 } (DB-03 fix)
|
|
156
|
+
* - 'enum:a,b,c' → { type: 'string', enum: ['a','b','c'] }
|
|
157
|
+
* - 'a|b|c' → { type: 'string', enum: ['a','b','c'] }
|
|
158
|
+
* - 'array!1-10' → { type: 'array', minItems:1, maxItems:10, _required:true }
|
|
159
|
+
*/
|
|
160
|
+
parseString(dslStr: string): JSONSchema {
|
|
161
|
+
if (!dslStr || typeof dslStr !== 'string') {
|
|
162
|
+
return { type: 'string' }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let s = dslStr.trim()
|
|
166
|
+
let required = false
|
|
167
|
+
|
|
168
|
+
// ========== Pre-processing 1: array!N-M special syntax (v1 compat) ==========
|
|
169
|
+
// 'array!1-10' → equivalent to 'array:1-10' + required=true
|
|
170
|
+
const arrayBangMatch = /^array!([\d-]+)$/.exec(s)
|
|
171
|
+
if (arrayBangMatch) {
|
|
172
|
+
s = `array:${arrayBangMatch[1]}`
|
|
173
|
+
required = true
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ========== Pre-processing 2: trailing '!' / '?' → required/optional marker, strip ==========
|
|
177
|
+
if (s.endsWith('!')) {
|
|
178
|
+
required = true
|
|
179
|
+
s = s.slice(0, -1)
|
|
180
|
+
} else if (s.endsWith('?')) {
|
|
181
|
+
s = s.slice(0, -1)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ========== Special case: pipe enum 'a|b|c' (no colon — entire segment is enum) ==========
|
|
185
|
+
if (s.includes('|') && !s.includes(':')) {
|
|
186
|
+
const rawValues = s.split('|').map(v => v.trim())
|
|
187
|
+
// Auto-detect type from values
|
|
188
|
+
if (rawValues.every(v => v === 'true' || v === 'false')) {
|
|
189
|
+
// Boolean enum
|
|
190
|
+
return {
|
|
191
|
+
type: 'boolean',
|
|
192
|
+
enum: rawValues.map(v => v === 'true'),
|
|
193
|
+
...(required ? { _required: true } : {}),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const numericValues = rawValues.map(v => Number(v))
|
|
197
|
+
if (rawValues.every((v, i) => v !== '' && !isNaN(numericValues[i]))) {
|
|
198
|
+
// Numeric enum — always use 'number' type for v1 compat
|
|
199
|
+
return {
|
|
200
|
+
type: 'number',
|
|
201
|
+
enum: numericValues,
|
|
202
|
+
...(required ? { _required: true } : {}),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// String enum (default)
|
|
206
|
+
return {
|
|
207
|
+
type: 'string',
|
|
208
|
+
enum: rawValues,
|
|
209
|
+
...(required ? { _required: true } : {}),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ========== Special case: enum: prefix ==========
|
|
214
|
+
// 'enum:a,b,c' → { type:'string', enum:['a','b','c'] }
|
|
215
|
+
// 'enum:number:1,2,3' → { type:'number', enum:[1,2,3] }
|
|
216
|
+
if (s.startsWith('enum:')) {
|
|
217
|
+
return DslParser._parseEnumSyntax(s, required)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ========== Special case: types: union type prefix (v1 compat) ==========
|
|
221
|
+
// 'types:string|number' → oneOf: [{ type:'string' }, { type:'number' }]
|
|
222
|
+
// 'types:string:3-10|number:0-100' → oneOf with constraints
|
|
223
|
+
// 'types:email|phone' → oneOf with format types
|
|
224
|
+
if (s.startsWith('types:')) {
|
|
225
|
+
return DslParser._parseUnionTypes(s.slice(6), required)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ========== Special case: array<TYPE> syntax ==========
|
|
229
|
+
// 'array<string>' → { type:'array', items:{ type:'string' } }
|
|
230
|
+
// 'array<enum:public|private>' → { type:'array', items:{ type:'string', enum:[...] } }
|
|
231
|
+
// 'array:1-5<string:1-20>' → { type:'array', minItems:1, maxItems:5, items:{ type:'string', minLength:1, maxLength:20 } }
|
|
232
|
+
const arrayAngleWithConstraintMatch = /^array:([^<]+)<(.+)>$/.exec(s)
|
|
233
|
+
if (arrayAngleWithConstraintMatch) {
|
|
234
|
+
const arrayConstraint = ConstraintParser.parse(arrayAngleWithConstraintMatch[1], 'array')
|
|
235
|
+
const itemSchema = DslParser.parseString(arrayAngleWithConstraintMatch[2])
|
|
236
|
+
return {
|
|
237
|
+
type: 'array',
|
|
238
|
+
...arrayConstraint,
|
|
239
|
+
items: itemSchema,
|
|
240
|
+
...(required ? { _required: true } : {}),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const arrayAngleMatch = /^array<(.+)>$/.exec(s)
|
|
244
|
+
if (arrayAngleMatch) {
|
|
245
|
+
const itemSchema = DslParser.parseString(arrayAngleMatch[1])
|
|
246
|
+
return {
|
|
247
|
+
type: 'array',
|
|
248
|
+
items: itemSchema,
|
|
249
|
+
...(required ? { _required: true } : {}),
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ========== Main parsing: typeName[:constraint] ==========
|
|
254
|
+
const colonIdx = s.indexOf(':')
|
|
255
|
+
let typeName: string
|
|
256
|
+
let constraintStr: string
|
|
257
|
+
|
|
258
|
+
if (colonIdx === -1) {
|
|
259
|
+
typeName = s
|
|
260
|
+
constraintStr = ''
|
|
261
|
+
} else {
|
|
262
|
+
typeName = s.slice(0, colonIdx)
|
|
263
|
+
constraintStr = s.slice(colonIdx + 1)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ========== Special case: pattern types (phone/idCard/creditCard/licensePlate/postalCode/passport) ==========
|
|
267
|
+
const PATTERN_TYPES = ['phone', 'idCard', 'creditCard', 'licensePlate', 'postalCode', 'passport'] as const
|
|
268
|
+
if (PATTERN_TYPES.includes(typeName as typeof PATTERN_TYPES[number])) {
|
|
269
|
+
const patternGroup = PATTERNS[typeName as keyof typeof PATTERNS] as Record<string, { pattern: RegExp; min?: number; max?: number; key: string }>
|
|
270
|
+
if (patternGroup) {
|
|
271
|
+
const arg = constraintStr || (typeName === 'creditCard' ? 'visa' : 'cn')
|
|
272
|
+
const cfg = patternGroup[arg.toLowerCase()]
|
|
273
|
+
if (cfg) {
|
|
274
|
+
return {
|
|
275
|
+
type: 'string',
|
|
276
|
+
pattern: cfg.pattern.source,
|
|
277
|
+
...(cfg.min !== undefined ? { minLength: cfg.min } : {}),
|
|
278
|
+
...(cfg.max !== undefined ? { maxLength: cfg.max } : {}),
|
|
279
|
+
_customMessages: { pattern: cfg.key },
|
|
280
|
+
...(required ? { _required: true } : {}),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
throw new Error(`[schema-dsl] Unsupported country/variant "${arg}" for type "${typeName}"`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// TypeRegistry resolution
|
|
288
|
+
const typeDef = TypeRegistry.resolve(typeName)
|
|
289
|
+
|
|
290
|
+
// ConstraintParser parse — use resolved base type (e.g., 'string' for 'alphanum')
|
|
291
|
+
const resolvedBaseType = (typeDef.baseSchema.type as string) ?? typeName
|
|
292
|
+
const constraints = ConstraintParser.parse(constraintStr, resolvedBaseType)
|
|
293
|
+
|
|
294
|
+
// SchemaCompiler assembly
|
|
295
|
+
const schema = SchemaCompiler.compile(typeDef, constraints, {
|
|
296
|
+
required,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
return schema
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Parse an object DSL definition → JSONSchema (type:object + properties + required[])
|
|
304
|
+
*/
|
|
305
|
+
parseObject(dslObj: DslDefinition): JSONSchema {
|
|
306
|
+
const schema: JSONSchema = {
|
|
307
|
+
type: 'object',
|
|
308
|
+
properties: {},
|
|
309
|
+
required: [],
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const [rawKey, value] of Object.entries(dslObj)) {
|
|
313
|
+
let fieldKey = rawKey
|
|
314
|
+
let isKeyRequired = false
|
|
315
|
+
|
|
316
|
+
// key! suffix marks this field as required
|
|
317
|
+
if (rawKey.endsWith('!')) {
|
|
318
|
+
fieldKey = rawKey.slice(0, -1)
|
|
319
|
+
isKeyRequired = true
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let fieldSchema: JSONSchema
|
|
323
|
+
|
|
324
|
+
if (typeof value === 'string') {
|
|
325
|
+
fieldSchema = DslParser.parseString(value)
|
|
326
|
+
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
327
|
+
const obj = value as Record<string, unknown>
|
|
328
|
+
if (obj['_isMatch']) {
|
|
329
|
+
if (!schema.allOf) schema.allOf = []
|
|
330
|
+
schema.allOf.push(_buildMatchSchema(String(obj['field']), fieldKey, obj['map'] as Record<string, unknown>))
|
|
331
|
+
fieldSchema = { description: `Depends on ${String(obj['field'])}` }
|
|
332
|
+
} else if (obj['_isIf']) {
|
|
333
|
+
if (!schema.allOf) schema.allOf = []
|
|
334
|
+
schema.allOf.push(_buildIfSchema(String(obj['condition']), fieldKey, obj['then'], obj['else']))
|
|
335
|
+
fieldSchema = { description: `Conditional field based on ${String(obj['condition'])}` }
|
|
336
|
+
} else if (typeof obj['toSchema'] === 'function') {
|
|
337
|
+
// DslBuilder instance or ConditionalBuilder (has toSchema method)
|
|
338
|
+
fieldSchema = (obj['toSchema'] as () => JSONSchema)()
|
|
339
|
+
} else if (_isRawJsonSchema(obj)) {
|
|
340
|
+
// Raw JSON Schema object (e.g., { type: 'object', properties: {...} }) — pass through as-is
|
|
341
|
+
fieldSchema = value as JSONSchema
|
|
342
|
+
} else {
|
|
343
|
+
// Nested DslDefinition (e.g., { street: 'string!', city: 'string!' })
|
|
344
|
+
fieldSchema = DslParser.parseObject(value as DslDefinition)
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
// Pass through as-is (compatible with direct schema fragment input)
|
|
348
|
+
fieldSchema = value as JSONSchema
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Apply required flag: key! takes priority over the field's internal _required marker
|
|
352
|
+
if (isKeyRequired) {
|
|
353
|
+
; (schema.required as string[]).push(fieldKey)
|
|
354
|
+
} else if (fieldSchema._required) {
|
|
355
|
+
; (schema.required as string[]).push(fieldKey)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Strip the internal _required marker
|
|
359
|
+
const { _required: _r, ...cleanSchema } = fieldSchema as JSONSchema & { _required?: boolean }
|
|
360
|
+
void _r
|
|
361
|
+
_copyHiddenSchemaProperties(fieldSchema as object, cleanSchema as object)
|
|
362
|
+
_cleanRequiredMarks(cleanSchema)
|
|
363
|
+
|
|
364
|
+
; (schema.properties as Record<string, JSONSchema>)[fieldKey] = cleanSchema
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if ((schema.required as string[]).length === 0) {
|
|
368
|
+
delete schema.required
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return schema
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
// --------------- Private helpers ---------------
|
|
375
|
+
|
|
376
|
+
/** Parse enum: prefix syntax */
|
|
377
|
+
_parseEnumSyntax(s: string, required: boolean): JSONSchema {
|
|
378
|
+
// Strip the 'enum:' prefix
|
|
379
|
+
const rest = s.slice('enum:'.length)
|
|
380
|
+
|
|
381
|
+
// Check for a type prefix: 'enum:number:1|2|3' or 'enum:number:1,2,3'
|
|
382
|
+
const typedEnumMatch = /^(string|number|integer|boolean):(.+)$/.exec(rest)
|
|
383
|
+
if (typedEnumMatch) {
|
|
384
|
+
const enumType = typedEnumMatch[1] as 'string' | 'number' | 'integer' | 'boolean'
|
|
385
|
+
const rawStr = typedEnumMatch[2]
|
|
386
|
+
const rawValues = (rawStr.includes('|') ? rawStr.split('|') : rawStr.split(',')).map(v => v.trim())
|
|
387
|
+
const values = DslParser._coerceEnumValues(rawValues, enumType)
|
|
388
|
+
return {
|
|
389
|
+
type: enumType,
|
|
390
|
+
enum: values,
|
|
391
|
+
...(required ? { _required: true } : {}),
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// No type prefix: default to string, supporting both '|' and ',' as separators
|
|
396
|
+
return {
|
|
397
|
+
type: 'string',
|
|
398
|
+
enum: (rest.includes('|') ? rest.split('|') : rest.split(',')).map(v => v.trim()),
|
|
399
|
+
...(required ? { _required: true } : {}),
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/** Convert a string array to enum values of the specified type */
|
|
404
|
+
_coerceEnumValues(
|
|
405
|
+
values: string[],
|
|
406
|
+
type: 'string' | 'number' | 'integer' | 'boolean'
|
|
407
|
+
): (string | number | boolean)[] {
|
|
408
|
+
if (type === 'number' || type === 'integer') {
|
|
409
|
+
return values.map(v => {
|
|
410
|
+
const n = parseFloat(v)
|
|
411
|
+
if (isNaN(n)) throw new Error(`[schema-dsl] Invalid number enum value: "${v}"`)
|
|
412
|
+
return n
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
if (type === 'boolean') {
|
|
416
|
+
return values.map(v => {
|
|
417
|
+
if (v !== 'true' && v !== 'false')
|
|
418
|
+
throw new Error(`[schema-dsl] Invalid boolean enum value: "${v}"`)
|
|
419
|
+
return v === 'true'
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
return values
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Parse types: union type syntax (v1 compatible)
|
|
427
|
+
*
|
|
428
|
+
* Splits 'string|number' into a oneOf array, parsing each segment independently.
|
|
429
|
+
* Uses smart splitting: 'string:3-10|number:0-100' → ['string:3-10', 'number:0-100']
|
|
430
|
+
* When only a single type is present, emits a plain schema instead of a oneOf wrapper.
|
|
431
|
+
*/
|
|
432
|
+
_parseUnionTypes(typesStr: string, required: boolean): JSONSchema {
|
|
433
|
+
// Smart split by | that is a type separator (not inside constraints)
|
|
434
|
+
const segments: string[] = []
|
|
435
|
+
let current = ''
|
|
436
|
+
let depth = 0
|
|
437
|
+
|
|
438
|
+
for (let i = 0; i < typesStr.length; i++) {
|
|
439
|
+
const ch = typesStr[i]
|
|
440
|
+
if (ch === '<') { depth++; current += ch }
|
|
441
|
+
else if (ch === '>') { depth--; current += ch }
|
|
442
|
+
else if (ch === '|' && depth === 0) {
|
|
443
|
+
if (current.trim()) segments.push(current.trim())
|
|
444
|
+
current = ''
|
|
445
|
+
} else {
|
|
446
|
+
current += ch
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (current.trim()) segments.push(current.trim())
|
|
450
|
+
|
|
451
|
+
if (segments.length === 0) {
|
|
452
|
+
throw new Error('[schema-dsl] types: requires at least one type')
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Single type → optimize to direct schema (no oneOf)
|
|
456
|
+
if (segments.length === 1) {
|
|
457
|
+
const schema = DslParser.parseString(segments[0])
|
|
458
|
+
if (required) schema._required = true
|
|
459
|
+
return schema
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Multiple types → oneOf
|
|
463
|
+
const oneOf = segments.map(seg => DslParser.parseString(seg))
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
oneOf,
|
|
467
|
+
...(required ? { _required: true } : {}),
|
|
468
|
+
} as JSONSchema
|
|
469
|
+
},
|
|
470
|
+
}
|