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
|
@@ -1,382 +1,382 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ConditionalBuilder — chainable condition builder.
|
|
3
|
-
*
|
|
4
|
-
* v2 fixes:
|
|
5
|
-
* C-03: assert() throws ValidationError instead of plain Error
|
|
6
|
-
* C-Y01: elseIf semantics correct
|
|
7
|
-
* C-Y02: build() as toSchema() alias (IConditionalBuilder interface compat)
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { JSONSchema } from '../types/schema.js'
|
|
11
|
-
import type { IConditionalBuilder } from '../types/conditional.js'
|
|
12
|
-
import type { ValidateOptions, ValidationResult } from '../types/validate.js'
|
|
13
|
-
import { ValidationError } from '../errors/ValidationError.js'
|
|
14
|
-
import { Validator } from './Validator.js'
|
|
15
|
-
import { Locale } from './Locale.js'
|
|
16
|
-
import { attachConditionalRuntime } from './ConditionalRuntime.js'
|
|
17
|
-
|
|
18
|
-
const RUNTIME_ONLY_VALIDATE_OPTION_KEYS = new Set(['locale', 'messages', 'format'])
|
|
19
|
-
|
|
20
|
-
// ==================== Internal Data Structures ====================
|
|
21
|
-
|
|
22
|
-
type ConditionFn = (data: unknown) => boolean
|
|
23
|
-
|
|
24
|
-
interface CombinedCondition {
|
|
25
|
-
op: 'root' | 'and' | 'or'
|
|
26
|
-
fn: ConditionFn
|
|
27
|
-
message: string | null
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface ConditionEntry {
|
|
31
|
-
type: 'if' | 'elseIf'
|
|
32
|
-
condition: ConditionFn
|
|
33
|
-
combinedConditions: CombinedCondition[]
|
|
34
|
-
message?: string
|
|
35
|
-
action?: 'throw'
|
|
36
|
-
then?: string | JSONSchema | null
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface EvaluateResult {
|
|
40
|
-
result: boolean
|
|
41
|
-
failedMessage: string | null
|
|
42
|
-
requirementFailed?: boolean
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ==================== ConditionalBuilder ====================
|
|
46
|
-
|
|
47
|
-
export class ConditionalBuilder implements IConditionalBuilder {
|
|
48
|
-
private _conditions: ConditionEntry[]
|
|
49
|
-
private _elseSchema: string | JSONSchema | null | undefined
|
|
50
|
-
|
|
51
|
-
constructor() {
|
|
52
|
-
this._conditions = []
|
|
53
|
-
this._elseSchema = undefined
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ==================== Condition Chain Methods ====================
|
|
57
|
-
|
|
58
|
-
if(conditionFn: ConditionFn | string): this {
|
|
59
|
-
// v1 compat: accept string field name and convert to function
|
|
60
|
-
if (typeof conditionFn === 'string') {
|
|
61
|
-
const fieldName = conditionFn
|
|
62
|
-
conditionFn = ((data: unknown) => Boolean((data as Record<string, unknown>)[fieldName])) as ConditionFn
|
|
63
|
-
}
|
|
64
|
-
if (typeof conditionFn !== 'function') {
|
|
65
|
-
throw new Error('[schema-dsl] Condition must be a function')
|
|
66
|
-
}
|
|
67
|
-
this._conditions.push({
|
|
68
|
-
type: 'if',
|
|
69
|
-
condition: conditionFn,
|
|
70
|
-
combinedConditions: [{ op: 'root', fn: conditionFn, message: null }],
|
|
71
|
-
})
|
|
72
|
-
return this
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
and(conditionFn: ConditionFn): this {
|
|
76
|
-
if (typeof conditionFn !== 'function') {
|
|
77
|
-
throw new Error('[schema-dsl] Condition must be a function')
|
|
78
|
-
}
|
|
79
|
-
const last = this._conditions[this._conditions.length - 1]
|
|
80
|
-
if (!last) throw new Error('[schema-dsl] .and() must follow .if() or .elseIf()')
|
|
81
|
-
last.combinedConditions.push({ op: 'and', fn: conditionFn, message: null })
|
|
82
|
-
return this
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* require(field) — v1 compat: require the specified field to be truthy.
|
|
87
|
-
* Equivalent to .and(data => Boolean(data[field])).
|
|
88
|
-
* BC-5 fix.
|
|
89
|
-
*/
|
|
90
|
-
require(field: string): this {
|
|
91
|
-
return this.and((data: unknown) => Boolean((data as Record<string, unknown>)[field]))
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
or(conditionFn: ConditionFn): this {
|
|
95
|
-
if (typeof conditionFn !== 'function') {
|
|
96
|
-
throw new Error('[schema-dsl] Condition must be a function')
|
|
97
|
-
}
|
|
98
|
-
const last = this._conditions[this._conditions.length - 1]
|
|
99
|
-
if (!last) throw new Error('[schema-dsl] .or() must follow .if() or .elseIf()')
|
|
100
|
-
last.combinedConditions.push({ op: 'or', fn: conditionFn, message: null })
|
|
101
|
-
return this
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
elseIf(conditionFn: ConditionFn): this {
|
|
105
|
-
if (typeof conditionFn !== 'function') {
|
|
106
|
-
throw new Error('[schema-dsl] Condition must be a function')
|
|
107
|
-
}
|
|
108
|
-
if (this._conditions.length === 0) {
|
|
109
|
-
throw new Error('[schema-dsl] .elseIf() must follow .if()')
|
|
110
|
-
}
|
|
111
|
-
this._conditions.push({
|
|
112
|
-
type: 'elseIf',
|
|
113
|
-
condition: conditionFn,
|
|
114
|
-
combinedConditions: [{ op: 'root', fn: conditionFn, message: null }],
|
|
115
|
-
})
|
|
116
|
-
return this
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
message(msg: string): this {
|
|
120
|
-
if (typeof msg !== 'string') {
|
|
121
|
-
throw new Error('[schema-dsl] Message must be a string')
|
|
122
|
-
}
|
|
123
|
-
const last = this._conditions[this._conditions.length - 1]
|
|
124
|
-
if (!last) throw new Error('[schema-dsl] .message() must follow .if() or .elseIf()')
|
|
125
|
-
|
|
126
|
-
const lastCombined = last.combinedConditions[last.combinedConditions.length - 1]
|
|
127
|
-
if (lastCombined) {
|
|
128
|
-
lastCombined.message = msg
|
|
129
|
-
}
|
|
130
|
-
last.message = msg
|
|
131
|
-
last.action = 'throw'
|
|
132
|
-
return this
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
then(schema: string | JSONSchema | null): this {
|
|
136
|
-
const last = this._conditions[this._conditions.length - 1]
|
|
137
|
-
if (!last) throw new Error('[schema-dsl] .then() must follow .if() or .elseIf()')
|
|
138
|
-
last.then = schema
|
|
139
|
-
return this
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
else(schema: string | JSONSchema | null): this {
|
|
143
|
-
this._elseSchema = schema
|
|
144
|
-
return this
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ==================== Output Methods ====================
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Produce a schema object carrying serialisable conditional metadata plus non-enumerable runtime state.
|
|
151
|
-
*/
|
|
152
|
-
toSchema(): JSONSchema {
|
|
153
|
-
return attachConditionalRuntime({
|
|
154
|
-
_isConditional: true,
|
|
155
|
-
_runtimeOnlyConditional: true,
|
|
156
|
-
else: this._elseSchema,
|
|
157
|
-
} as unknown as JSONSchema, {
|
|
158
|
-
conditions: this._conditions,
|
|
159
|
-
elseSchema: this._elseSchema,
|
|
160
|
-
evaluateCondition: (conditionObj: unknown, data: unknown) =>
|
|
161
|
-
this._evaluateCondition(conditionObj as ConditionEntry, data),
|
|
162
|
-
}) as unknown as JSONSchema
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* build() — alias for toSchema() (IConditionalBuilder interface compat).
|
|
167
|
-
*/
|
|
168
|
-
build(): JSONSchema {
|
|
169
|
-
return this.toSchema()
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ==================== Validation Methods ====================
|
|
173
|
-
|
|
174
|
-
private readonly _validatorCache = new Map<string, Validator>()
|
|
175
|
-
private static readonly _VALIDATOR_CACHE_MAX = 20
|
|
176
|
-
|
|
177
|
-
validate(data: unknown, options: Record<string, unknown> = {}): ValidationResult<unknown> {
|
|
178
|
-
const validator = this._getValidator(options)
|
|
179
|
-
return validator.validate(this.toSchema(), data, options)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async validateAsync(data: unknown, options: Record<string, unknown> = {}): Promise<ValidationResult<unknown>> {
|
|
183
|
-
const validator = this._getValidator(options)
|
|
184
|
-
// validator.validateAsync() throws ValidationError on failure and returns data on success.
|
|
185
|
-
// Adapt to the ValidationResult contract expected by ConditionalBuilder callers.
|
|
186
|
-
try {
|
|
187
|
-
const resultData = await validator.validateAsync(this.toSchema(), data, options)
|
|
188
|
-
return { valid: true, data: resultData as unknown, errors: [] }
|
|
189
|
-
} catch (err) {
|
|
190
|
-
if (err instanceof ValidationError) {
|
|
191
|
-
return { valid: false, errors: err.errors, data: undefined }
|
|
192
|
-
}
|
|
193
|
-
throw err
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* assert() — synchronous assertion; throws ValidationError on failure (fixes C-03: v1 threw plain Error).
|
|
199
|
-
* Evaluates conditions synchronously without going through Validator (which is async).
|
|
200
|
-
*/
|
|
201
|
-
assert(data: unknown, options: Record<string, unknown> = {}): unknown {
|
|
202
|
-
const locale = (options.locale as string) ?? null
|
|
203
|
-
for (const cond of this._conditions) {
|
|
204
|
-
const { result: matched, failedMessage } = this._evaluateCondition(cond, data)
|
|
205
|
-
if (matched && cond.action === 'throw') {
|
|
206
|
-
const rawMsg = failedMessage ?? cond.message ?? 'Condition failed'
|
|
207
|
-
const message = Locale.getMessageText(rawMsg, {}, locale)
|
|
208
|
-
throw new ValidationError(
|
|
209
|
-
[{ message, path: '', keyword: 'conditional', params: {} }],
|
|
210
|
-
data,
|
|
211
|
-
)
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return data
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
check(data: unknown): boolean {
|
|
218
|
-
try {
|
|
219
|
-
const conditions = this._conditions
|
|
220
|
-
for (const cond of conditions) {
|
|
221
|
-
const { result: matched } = this._evaluateCondition(cond, data)
|
|
222
|
-
if (matched && cond.action === 'throw') return false
|
|
223
|
-
}
|
|
224
|
-
return true
|
|
225
|
-
} catch {
|
|
226
|
-
return false
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// ==================== Static Factory Methods ====================
|
|
231
|
-
|
|
232
|
-
static start(conditionFn: ConditionFn | string): ConditionalBuilder {
|
|
233
|
-
return new ConditionalBuilder().if(conditionFn)
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ==================== Internal Evaluation Logic ====================
|
|
237
|
-
|
|
238
|
-
private _evaluateCondition(conditionObj: ConditionEntry, data: unknown): EvaluateResult {
|
|
239
|
-
try {
|
|
240
|
-
const isMessageMode = conditionObj.action === 'throw'
|
|
241
|
-
const hasOrConditions = conditionObj.combinedConditions.some(c => c.op === 'or')
|
|
242
|
-
|
|
243
|
-
// Chain check mode (v1 compat): message mode + root has own message
|
|
244
|
-
// Each condition checked left-to-right, first TRUE = fail with its message
|
|
245
|
-
const rootHasMessage = conditionObj.combinedConditions[0]?.message !== null
|
|
246
|
-
const isChainCheckMode = isMessageMode && rootHasMessage
|
|
247
|
-
|
|
248
|
-
if (isChainCheckMode) {
|
|
249
|
-
for (const combined of conditionObj.combinedConditions) {
|
|
250
|
-
try {
|
|
251
|
-
const conditionResult = combined.fn(data)
|
|
252
|
-
if (conditionResult) {
|
|
253
|
-
return { result: true, failedMessage: combined.message ?? conditionObj.message ?? null }
|
|
254
|
-
}
|
|
255
|
-
} catch {
|
|
256
|
-
// Condition threw — treat as not matched
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return { result: false, failedMessage: null }
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Message mode with AND only (no OR, shared message, root has no own message):
|
|
263
|
-
// ALL conditions must be true to trigger
|
|
264
|
-
if (isMessageMode && !hasOrConditions && conditionObj.combinedConditions.length > 1) {
|
|
265
|
-
let allTrue = true
|
|
266
|
-
for (const combined of conditionObj.combinedConditions) {
|
|
267
|
-
if (!combined.fn(data)) {
|
|
268
|
-
allTrue = false
|
|
269
|
-
break
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
if (allTrue) {
|
|
273
|
-
return { result: true, failedMessage: conditionObj.message ?? null }
|
|
274
|
-
}
|
|
275
|
-
return { result: false, failedMessage: null }
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Message mode with OR (and possibly AND, shared message): AND/OR boolean with precedence
|
|
279
|
-
if (isMessageMode && hasOrConditions) {
|
|
280
|
-
let andGroupResult = true
|
|
281
|
-
let finalResult = false
|
|
282
|
-
for (const combined of conditionObj.combinedConditions) {
|
|
283
|
-
const conditionResult = combined.fn(data)
|
|
284
|
-
if (combined.op === 'root' || combined.op === 'and') {
|
|
285
|
-
andGroupResult = andGroupResult && conditionResult
|
|
286
|
-
} else if (combined.op === 'or') {
|
|
287
|
-
finalResult = finalResult || andGroupResult
|
|
288
|
-
andGroupResult = conditionResult
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
finalResult = finalResult || andGroupResult
|
|
292
|
-
if (finalResult) {
|
|
293
|
-
return { result: true, failedMessage: conditionObj.message ?? null }
|
|
294
|
-
}
|
|
295
|
-
return { result: false, failedMessage: null }
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Message mode without AND/OR (single condition)
|
|
299
|
-
if (isMessageMode) {
|
|
300
|
-
const root = conditionObj.combinedConditions[0]
|
|
301
|
-
if (root && root.fn(data)) {
|
|
302
|
-
return { result: true, failedMessage: root.message ?? conditionObj.message ?? null }
|
|
303
|
-
}
|
|
304
|
-
return { result: false, failedMessage: null }
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Non-message (then/else) mode: standard AND/OR boolean evaluation
|
|
308
|
-
let andGroupResult = true
|
|
309
|
-
let finalResult = false
|
|
310
|
-
for (const combined of conditionObj.combinedConditions) {
|
|
311
|
-
const conditionResult = combined.fn(data)
|
|
312
|
-
if (combined.op === 'root' || combined.op === 'and') {
|
|
313
|
-
andGroupResult = andGroupResult && conditionResult
|
|
314
|
-
} else if (combined.op === 'or') {
|
|
315
|
-
finalResult = finalResult || andGroupResult
|
|
316
|
-
andGroupResult = conditionResult
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
const result = finalResult || andGroupResult
|
|
320
|
-
return { result, failedMessage: null }
|
|
321
|
-
} catch {
|
|
322
|
-
return { result: false, failedMessage: null }
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
private _getValidator(options: Record<string, unknown>): Validator {
|
|
327
|
-
const constructorOptions = this._getConstructorOptions(options)
|
|
328
|
-
const cacheKey = this._getValidatorCacheKey(constructorOptions)
|
|
329
|
-
|
|
330
|
-
let validator = this._validatorCache.get(cacheKey)
|
|
331
|
-
if (!validator) {
|
|
332
|
-
if (this._validatorCache.size >= ConditionalBuilder._VALIDATOR_CACHE_MAX) {
|
|
333
|
-
const firstKey = this._validatorCache.keys().next().value
|
|
334
|
-
if (firstKey !== undefined) this._validatorCache.delete(firstKey)
|
|
335
|
-
}
|
|
336
|
-
validator = new Validator(constructorOptions)
|
|
337
|
-
this._validatorCache.set(cacheKey, validator)
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return validator
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
private _getConstructorOptions(options: Record<string, unknown>): ValidateOptions {
|
|
344
|
-
const constructorOptions: Record<string, unknown> = {}
|
|
345
|
-
|
|
346
|
-
for (const [key, value] of Object.entries(options)) {
|
|
347
|
-
if (!RUNTIME_ONLY_VALIDATE_OPTION_KEYS.has(key)) {
|
|
348
|
-
constructorOptions[key] = value
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
return constructorOptions as ValidateOptions
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
private _getValidatorCacheKey(options: ValidateOptions): string {
|
|
356
|
-
return JSON.stringify(this._normalizeOptionValue(options))
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
private _normalizeOptionValue(value: unknown): unknown {
|
|
360
|
-
if (Array.isArray(value)) {
|
|
361
|
-
return value.map(item => this._normalizeOptionValue(item))
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (value && typeof value === 'object') {
|
|
365
|
-
return Object.fromEntries(
|
|
366
|
-
Object.entries(value as Record<string, unknown>)
|
|
367
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
368
|
-
.map(([key, nestedValue]) => [key, this._normalizeOptionValue(nestedValue)])
|
|
369
|
-
)
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (typeof value === 'function') {
|
|
373
|
-
return `__fn__:${value.name || 'anonymous'}`
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
if (typeof value === 'bigint') {
|
|
377
|
-
return `__bigint__:${value.toString()}`
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
return value
|
|
381
|
-
}
|
|
382
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* ConditionalBuilder — chainable condition builder.
|
|
3
|
+
*
|
|
4
|
+
* v2 fixes:
|
|
5
|
+
* C-03: assert() throws ValidationError instead of plain Error
|
|
6
|
+
* C-Y01: elseIf semantics correct
|
|
7
|
+
* C-Y02: build() as toSchema() alias (IConditionalBuilder interface compat)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { JSONSchema } from '../types/schema.js'
|
|
11
|
+
import type { IConditionalBuilder } from '../types/conditional.js'
|
|
12
|
+
import type { ValidateOptions, ValidationResult } from '../types/validate.js'
|
|
13
|
+
import { ValidationError } from '../errors/ValidationError.js'
|
|
14
|
+
import { Validator } from './Validator.js'
|
|
15
|
+
import { Locale } from './Locale.js'
|
|
16
|
+
import { attachConditionalRuntime } from './ConditionalRuntime.js'
|
|
17
|
+
|
|
18
|
+
const RUNTIME_ONLY_VALIDATE_OPTION_KEYS = new Set(['locale', 'messages', 'format'])
|
|
19
|
+
|
|
20
|
+
// ==================== Internal Data Structures ====================
|
|
21
|
+
|
|
22
|
+
type ConditionFn = (data: unknown) => boolean
|
|
23
|
+
|
|
24
|
+
interface CombinedCondition {
|
|
25
|
+
op: 'root' | 'and' | 'or'
|
|
26
|
+
fn: ConditionFn
|
|
27
|
+
message: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ConditionEntry {
|
|
31
|
+
type: 'if' | 'elseIf'
|
|
32
|
+
condition: ConditionFn
|
|
33
|
+
combinedConditions: CombinedCondition[]
|
|
34
|
+
message?: string
|
|
35
|
+
action?: 'throw'
|
|
36
|
+
then?: string | JSONSchema | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface EvaluateResult {
|
|
40
|
+
result: boolean
|
|
41
|
+
failedMessage: string | null
|
|
42
|
+
requirementFailed?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ==================== ConditionalBuilder ====================
|
|
46
|
+
|
|
47
|
+
export class ConditionalBuilder implements IConditionalBuilder {
|
|
48
|
+
private _conditions: ConditionEntry[]
|
|
49
|
+
private _elseSchema: string | JSONSchema | null | undefined
|
|
50
|
+
|
|
51
|
+
constructor() {
|
|
52
|
+
this._conditions = []
|
|
53
|
+
this._elseSchema = undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ==================== Condition Chain Methods ====================
|
|
57
|
+
|
|
58
|
+
if(conditionFn: ConditionFn | string): this {
|
|
59
|
+
// v1 compat: accept string field name and convert to function
|
|
60
|
+
if (typeof conditionFn === 'string') {
|
|
61
|
+
const fieldName = conditionFn
|
|
62
|
+
conditionFn = ((data: unknown) => Boolean((data as Record<string, unknown>)[fieldName])) as ConditionFn
|
|
63
|
+
}
|
|
64
|
+
if (typeof conditionFn !== 'function') {
|
|
65
|
+
throw new Error('[schema-dsl] Condition must be a function')
|
|
66
|
+
}
|
|
67
|
+
this._conditions.push({
|
|
68
|
+
type: 'if',
|
|
69
|
+
condition: conditionFn,
|
|
70
|
+
combinedConditions: [{ op: 'root', fn: conditionFn, message: null }],
|
|
71
|
+
})
|
|
72
|
+
return this
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
and(conditionFn: ConditionFn): this {
|
|
76
|
+
if (typeof conditionFn !== 'function') {
|
|
77
|
+
throw new Error('[schema-dsl] Condition must be a function')
|
|
78
|
+
}
|
|
79
|
+
const last = this._conditions[this._conditions.length - 1]
|
|
80
|
+
if (!last) throw new Error('[schema-dsl] .and() must follow .if() or .elseIf()')
|
|
81
|
+
last.combinedConditions.push({ op: 'and', fn: conditionFn, message: null })
|
|
82
|
+
return this
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* require(field) — v1 compat: require the specified field to be truthy.
|
|
87
|
+
* Equivalent to .and(data => Boolean(data[field])).
|
|
88
|
+
* BC-5 fix.
|
|
89
|
+
*/
|
|
90
|
+
require(field: string): this {
|
|
91
|
+
return this.and((data: unknown) => Boolean((data as Record<string, unknown>)[field]))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
or(conditionFn: ConditionFn): this {
|
|
95
|
+
if (typeof conditionFn !== 'function') {
|
|
96
|
+
throw new Error('[schema-dsl] Condition must be a function')
|
|
97
|
+
}
|
|
98
|
+
const last = this._conditions[this._conditions.length - 1]
|
|
99
|
+
if (!last) throw new Error('[schema-dsl] .or() must follow .if() or .elseIf()')
|
|
100
|
+
last.combinedConditions.push({ op: 'or', fn: conditionFn, message: null })
|
|
101
|
+
return this
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
elseIf(conditionFn: ConditionFn): this {
|
|
105
|
+
if (typeof conditionFn !== 'function') {
|
|
106
|
+
throw new Error('[schema-dsl] Condition must be a function')
|
|
107
|
+
}
|
|
108
|
+
if (this._conditions.length === 0) {
|
|
109
|
+
throw new Error('[schema-dsl] .elseIf() must follow .if()')
|
|
110
|
+
}
|
|
111
|
+
this._conditions.push({
|
|
112
|
+
type: 'elseIf',
|
|
113
|
+
condition: conditionFn,
|
|
114
|
+
combinedConditions: [{ op: 'root', fn: conditionFn, message: null }],
|
|
115
|
+
})
|
|
116
|
+
return this
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
message(msg: string): this {
|
|
120
|
+
if (typeof msg !== 'string') {
|
|
121
|
+
throw new Error('[schema-dsl] Message must be a string')
|
|
122
|
+
}
|
|
123
|
+
const last = this._conditions[this._conditions.length - 1]
|
|
124
|
+
if (!last) throw new Error('[schema-dsl] .message() must follow .if() or .elseIf()')
|
|
125
|
+
|
|
126
|
+
const lastCombined = last.combinedConditions[last.combinedConditions.length - 1]
|
|
127
|
+
if (lastCombined) {
|
|
128
|
+
lastCombined.message = msg
|
|
129
|
+
}
|
|
130
|
+
last.message = msg
|
|
131
|
+
last.action = 'throw'
|
|
132
|
+
return this
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
then(schema: string | JSONSchema | null): this {
|
|
136
|
+
const last = this._conditions[this._conditions.length - 1]
|
|
137
|
+
if (!last) throw new Error('[schema-dsl] .then() must follow .if() or .elseIf()')
|
|
138
|
+
last.then = schema
|
|
139
|
+
return this
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
else(schema: string | JSONSchema | null): this {
|
|
143
|
+
this._elseSchema = schema
|
|
144
|
+
return this
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ==================== Output Methods ====================
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Produce a schema object carrying serialisable conditional metadata plus non-enumerable runtime state.
|
|
151
|
+
*/
|
|
152
|
+
toSchema(): JSONSchema {
|
|
153
|
+
return attachConditionalRuntime({
|
|
154
|
+
_isConditional: true,
|
|
155
|
+
_runtimeOnlyConditional: true,
|
|
156
|
+
else: this._elseSchema,
|
|
157
|
+
} as unknown as JSONSchema, {
|
|
158
|
+
conditions: this._conditions,
|
|
159
|
+
elseSchema: this._elseSchema,
|
|
160
|
+
evaluateCondition: (conditionObj: unknown, data: unknown) =>
|
|
161
|
+
this._evaluateCondition(conditionObj as ConditionEntry, data),
|
|
162
|
+
}) as unknown as JSONSchema
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* build() — alias for toSchema() (IConditionalBuilder interface compat).
|
|
167
|
+
*/
|
|
168
|
+
build(): JSONSchema {
|
|
169
|
+
return this.toSchema()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ==================== Validation Methods ====================
|
|
173
|
+
|
|
174
|
+
private readonly _validatorCache = new Map<string, Validator>()
|
|
175
|
+
private static readonly _VALIDATOR_CACHE_MAX = 20
|
|
176
|
+
|
|
177
|
+
validate(data: unknown, options: Record<string, unknown> = {}): ValidationResult<unknown> {
|
|
178
|
+
const validator = this._getValidator(options)
|
|
179
|
+
return validator.validate(this.toSchema(), data, options)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async validateAsync(data: unknown, options: Record<string, unknown> = {}): Promise<ValidationResult<unknown>> {
|
|
183
|
+
const validator = this._getValidator(options)
|
|
184
|
+
// validator.validateAsync() throws ValidationError on failure and returns data on success.
|
|
185
|
+
// Adapt to the ValidationResult contract expected by ConditionalBuilder callers.
|
|
186
|
+
try {
|
|
187
|
+
const resultData = await validator.validateAsync(this.toSchema(), data, options)
|
|
188
|
+
return { valid: true, data: resultData as unknown, errors: [] }
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err instanceof ValidationError) {
|
|
191
|
+
return { valid: false, errors: err.errors, data: undefined }
|
|
192
|
+
}
|
|
193
|
+
throw err
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* assert() — synchronous assertion; throws ValidationError on failure (fixes C-03: v1 threw plain Error).
|
|
199
|
+
* Evaluates conditions synchronously without going through Validator (which is async).
|
|
200
|
+
*/
|
|
201
|
+
assert(data: unknown, options: Record<string, unknown> = {}): unknown {
|
|
202
|
+
const locale = (options.locale as string) ?? null
|
|
203
|
+
for (const cond of this._conditions) {
|
|
204
|
+
const { result: matched, failedMessage } = this._evaluateCondition(cond, data)
|
|
205
|
+
if (matched && cond.action === 'throw') {
|
|
206
|
+
const rawMsg = failedMessage ?? cond.message ?? 'Condition failed'
|
|
207
|
+
const message = Locale.getMessageText(rawMsg, {}, locale)
|
|
208
|
+
throw new ValidationError(
|
|
209
|
+
[{ message, path: '', keyword: 'conditional', params: {} }],
|
|
210
|
+
data,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return data
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
check(data: unknown): boolean {
|
|
218
|
+
try {
|
|
219
|
+
const conditions = this._conditions
|
|
220
|
+
for (const cond of conditions) {
|
|
221
|
+
const { result: matched } = this._evaluateCondition(cond, data)
|
|
222
|
+
if (matched && cond.action === 'throw') return false
|
|
223
|
+
}
|
|
224
|
+
return true
|
|
225
|
+
} catch {
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ==================== Static Factory Methods ====================
|
|
231
|
+
|
|
232
|
+
static start(conditionFn: ConditionFn | string): ConditionalBuilder {
|
|
233
|
+
return new ConditionalBuilder().if(conditionFn)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ==================== Internal Evaluation Logic ====================
|
|
237
|
+
|
|
238
|
+
private _evaluateCondition(conditionObj: ConditionEntry, data: unknown): EvaluateResult {
|
|
239
|
+
try {
|
|
240
|
+
const isMessageMode = conditionObj.action === 'throw'
|
|
241
|
+
const hasOrConditions = conditionObj.combinedConditions.some(c => c.op === 'or')
|
|
242
|
+
|
|
243
|
+
// Chain check mode (v1 compat): message mode + root has own message
|
|
244
|
+
// Each condition checked left-to-right, first TRUE = fail with its message
|
|
245
|
+
const rootHasMessage = conditionObj.combinedConditions[0]?.message !== null
|
|
246
|
+
const isChainCheckMode = isMessageMode && rootHasMessage
|
|
247
|
+
|
|
248
|
+
if (isChainCheckMode) {
|
|
249
|
+
for (const combined of conditionObj.combinedConditions) {
|
|
250
|
+
try {
|
|
251
|
+
const conditionResult = combined.fn(data)
|
|
252
|
+
if (conditionResult) {
|
|
253
|
+
return { result: true, failedMessage: combined.message ?? conditionObj.message ?? null }
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// Condition threw — treat as not matched
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { result: false, failedMessage: null }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Message mode with AND only (no OR, shared message, root has no own message):
|
|
263
|
+
// ALL conditions must be true to trigger
|
|
264
|
+
if (isMessageMode && !hasOrConditions && conditionObj.combinedConditions.length > 1) {
|
|
265
|
+
let allTrue = true
|
|
266
|
+
for (const combined of conditionObj.combinedConditions) {
|
|
267
|
+
if (!combined.fn(data)) {
|
|
268
|
+
allTrue = false
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (allTrue) {
|
|
273
|
+
return { result: true, failedMessage: conditionObj.message ?? null }
|
|
274
|
+
}
|
|
275
|
+
return { result: false, failedMessage: null }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Message mode with OR (and possibly AND, shared message): AND/OR boolean with precedence
|
|
279
|
+
if (isMessageMode && hasOrConditions) {
|
|
280
|
+
let andGroupResult = true
|
|
281
|
+
let finalResult = false
|
|
282
|
+
for (const combined of conditionObj.combinedConditions) {
|
|
283
|
+
const conditionResult = combined.fn(data)
|
|
284
|
+
if (combined.op === 'root' || combined.op === 'and') {
|
|
285
|
+
andGroupResult = andGroupResult && conditionResult
|
|
286
|
+
} else if (combined.op === 'or') {
|
|
287
|
+
finalResult = finalResult || andGroupResult
|
|
288
|
+
andGroupResult = conditionResult
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
finalResult = finalResult || andGroupResult
|
|
292
|
+
if (finalResult) {
|
|
293
|
+
return { result: true, failedMessage: conditionObj.message ?? null }
|
|
294
|
+
}
|
|
295
|
+
return { result: false, failedMessage: null }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Message mode without AND/OR (single condition)
|
|
299
|
+
if (isMessageMode) {
|
|
300
|
+
const root = conditionObj.combinedConditions[0]
|
|
301
|
+
if (root && root.fn(data)) {
|
|
302
|
+
return { result: true, failedMessage: root.message ?? conditionObj.message ?? null }
|
|
303
|
+
}
|
|
304
|
+
return { result: false, failedMessage: null }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Non-message (then/else) mode: standard AND/OR boolean evaluation
|
|
308
|
+
let andGroupResult = true
|
|
309
|
+
let finalResult = false
|
|
310
|
+
for (const combined of conditionObj.combinedConditions) {
|
|
311
|
+
const conditionResult = combined.fn(data)
|
|
312
|
+
if (combined.op === 'root' || combined.op === 'and') {
|
|
313
|
+
andGroupResult = andGroupResult && conditionResult
|
|
314
|
+
} else if (combined.op === 'or') {
|
|
315
|
+
finalResult = finalResult || andGroupResult
|
|
316
|
+
andGroupResult = conditionResult
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const result = finalResult || andGroupResult
|
|
320
|
+
return { result, failedMessage: null }
|
|
321
|
+
} catch {
|
|
322
|
+
return { result: false, failedMessage: null }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private _getValidator(options: Record<string, unknown>): Validator {
|
|
327
|
+
const constructorOptions = this._getConstructorOptions(options)
|
|
328
|
+
const cacheKey = this._getValidatorCacheKey(constructorOptions)
|
|
329
|
+
|
|
330
|
+
let validator = this._validatorCache.get(cacheKey)
|
|
331
|
+
if (!validator) {
|
|
332
|
+
if (this._validatorCache.size >= ConditionalBuilder._VALIDATOR_CACHE_MAX) {
|
|
333
|
+
const firstKey = this._validatorCache.keys().next().value
|
|
334
|
+
if (firstKey !== undefined) this._validatorCache.delete(firstKey)
|
|
335
|
+
}
|
|
336
|
+
validator = new Validator(constructorOptions)
|
|
337
|
+
this._validatorCache.set(cacheKey, validator)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return validator
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private _getConstructorOptions(options: Record<string, unknown>): ValidateOptions {
|
|
344
|
+
const constructorOptions: Record<string, unknown> = {}
|
|
345
|
+
|
|
346
|
+
for (const [key, value] of Object.entries(options)) {
|
|
347
|
+
if (!RUNTIME_ONLY_VALIDATE_OPTION_KEYS.has(key)) {
|
|
348
|
+
constructorOptions[key] = value
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return constructorOptions as ValidateOptions
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private _getValidatorCacheKey(options: ValidateOptions): string {
|
|
356
|
+
return JSON.stringify(this._normalizeOptionValue(options))
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private _normalizeOptionValue(value: unknown): unknown {
|
|
360
|
+
if (Array.isArray(value)) {
|
|
361
|
+
return value.map(item => this._normalizeOptionValue(item))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (value && typeof value === 'object') {
|
|
365
|
+
return Object.fromEntries(
|
|
366
|
+
Object.entries(value as Record<string, unknown>)
|
|
367
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
368
|
+
.map(([key, nestedValue]) => [key, this._normalizeOptionValue(nestedValue)])
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (typeof value === 'function') {
|
|
373
|
+
return `__fn__:${value.name || 'anonymous'}`
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (typeof value === 'bigint') {
|
|
377
|
+
return `__bigint__:${value.toString()}`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return value
|
|
381
|
+
}
|
|
382
|
+
}
|