schema-dsl 1.2.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/CHANGELOG.md +87 -210
  2. package/README.md +391 -2249
  3. package/dist/DslBuilder-DQDN0ZxZ.d.cts +341 -0
  4. package/dist/DslBuilder-DkLaOo9Q.d.ts +341 -0
  5. package/dist/Validator-C7GsVQOH.d.cts +192 -0
  6. package/dist/Validator-hFWKGxir.d.ts +192 -0
  7. package/dist/index.cjs +6594 -0
  8. package/dist/index.d.cts +1145 -0
  9. package/dist/index.d.ts +1145 -0
  10. package/dist/index.js +6528 -0
  11. package/dist/plugin-CIKtTMtS.d.cts +246 -0
  12. package/dist/plugin-CIKtTMtS.d.ts +246 -0
  13. package/dist/plugins/custom-format.cjs +3802 -0
  14. package/dist/plugins/custom-format.d.cts +12 -0
  15. package/dist/plugins/custom-format.d.ts +12 -0
  16. package/dist/plugins/custom-format.js +3772 -0
  17. package/dist/plugins/custom-type-example.cjs +3795 -0
  18. package/dist/plugins/custom-type-example.d.cts +8 -0
  19. package/dist/plugins/custom-type-example.d.ts +8 -0
  20. package/dist/plugins/custom-type-example.js +3765 -0
  21. package/dist/plugins/custom-validator.cjs +146 -0
  22. package/dist/plugins/custom-validator.d.cts +10 -0
  23. package/dist/plugins/custom-validator.d.ts +10 -0
  24. package/dist/plugins/custom-validator.js +121 -0
  25. package/docs/FEATURE-INDEX.md +102 -68
  26. package/docs/add-custom-locale.md +48 -35
  27. package/docs/add-keyword.md +24 -0
  28. package/docs/api-reference.md +396 -154
  29. package/docs/api.md +13 -0
  30. package/docs/best-practices-project-structure.md +19 -10
  31. package/docs/best-practices.md +93 -53
  32. package/docs/cache-manager.md +23 -15
  33. package/docs/compile.md +45 -0
  34. package/docs/conditional-api.md +40 -11
  35. package/docs/custom-extensions-guide.md +80 -152
  36. package/docs/design-philosophy.md +76 -71
  37. package/docs/doc-index.md +324 -0
  38. package/docs/dsl-syntax.md +69 -19
  39. package/docs/dynamic-locale.md +24 -14
  40. package/docs/enum.md +12 -5
  41. package/docs/error-handling.md +53 -44
  42. package/docs/export-guide.md +47 -8
  43. package/docs/export-limitations.md +27 -11
  44. package/docs/faq.md +86 -67
  45. package/docs/frontend-i18n-guide.md +26 -12
  46. package/docs/i18n-user-guide.md +60 -47
  47. package/docs/i18n.md +51 -32
  48. package/docs/index.md +48 -0
  49. package/docs/json-schema-basics.md +40 -0
  50. package/docs/label-vs-description.md +12 -3
  51. package/docs/markdown-exporter.md +15 -6
  52. package/docs/mongodb-exporter.md +11 -4
  53. package/docs/multi-language.md +26 -0
  54. package/docs/multi-type-support.md +26 -33
  55. package/docs/mysql-exporter.md +9 -2
  56. package/docs/number-operators.md +12 -5
  57. package/docs/optional-marker-guide.md +28 -23
  58. package/docs/performance-guide.md +49 -0
  59. package/docs/plugin-system.md +205 -366
  60. package/docs/plugin-type-registration.md +34 -0
  61. package/docs/postgresql-exporter.md +9 -2
  62. package/docs/public/favicon.svg +5 -0
  63. package/docs/quick-start.md +37 -363
  64. package/docs/runtime-locale-support.md +20 -9
  65. package/docs/schema-helper.md +10 -5
  66. package/docs/schema-utils-advanced-issues.md +23 -0
  67. package/docs/schema-utils-best-practices.md +20 -0
  68. package/docs/schema-utils-chaining.md +7 -0
  69. package/docs/schema-utils.md +76 -42
  70. package/docs/security-checklist.md +20 -0
  71. package/docs/string-extensions.md +17 -9
  72. package/docs/troubleshooting.md +36 -21
  73. package/docs/type-converter.md +41 -50
  74. package/docs/type-reference.md +38 -15
  75. package/docs/typescript-guide.md +53 -42
  76. package/docs/union-type-guide.md +11 -1
  77. package/docs/union-types.md +10 -3
  78. package/docs/validate-async.md +36 -25
  79. package/docs/validate-batch.md +49 -0
  80. package/docs/validate-dsl-object-support.md +33 -28
  81. package/docs/validate.md +36 -16
  82. package/docs/validation-guide.md +25 -7
  83. package/docs/validator.md +39 -0
  84. package/package.json +85 -27
  85. package/plugins/custom-format.cjs +8 -0
  86. package/plugins/custom-type-example.cjs +8 -0
  87. package/plugins/custom-validator.cjs +8 -0
  88. package/src/adapters/DslAdapter.ts +111 -0
  89. package/src/adapters/index.ts +1 -0
  90. package/src/config/constants.ts +83 -0
  91. package/src/config/index.ts +2 -0
  92. package/src/config/patterns.ts +77 -0
  93. package/src/core/CacheManager.ts +159 -0
  94. package/src/core/ConditionalBuilder.ts +382 -0
  95. package/src/core/ConditionalRuntime.ts +28 -0
  96. package/src/core/ConditionalValidator.ts +255 -0
  97. package/src/core/DslBuilder.ts +677 -0
  98. package/src/core/ErrorCodes.ts +38 -0
  99. package/src/core/ErrorFormatter.ts +271 -0
  100. package/src/core/JSONSchemaCore.ts +65 -0
  101. package/src/core/Locale.ts +187 -0
  102. package/src/core/MessageTemplate.ts +42 -0
  103. package/src/core/ObjectDslBuilder.ts +64 -0
  104. package/src/core/PluginManager.ts +326 -0
  105. package/src/core/StringExtensions.ts +140 -0
  106. package/src/core/TemplateEngine.ts +44 -0
  107. package/src/core/Validator.ts +448 -0
  108. package/src/errors/I18nError.ts +159 -0
  109. package/src/errors/ValidationError.ts +105 -0
  110. package/src/exporters/BaseExporter.ts +60 -0
  111. package/src/exporters/MarkdownExporter.ts +305 -0
  112. package/src/exporters/MongoDBExporter.ts +126 -0
  113. package/src/exporters/MySQLExporter.ts +155 -0
  114. package/src/exporters/PostgreSQLExporter.ts +222 -0
  115. package/src/exporters/index.ts +18 -0
  116. package/src/index.ts +633 -0
  117. package/{lib/locales/en-US.js → src/locales/en-US.ts} +21 -37
  118. package/{lib/locales/es-ES.js → src/locales/es-ES.ts} +63 -16
  119. package/{lib/locales/fr-FR.js → src/locales/fr-FR.ts} +74 -27
  120. package/src/locales/index.ts +103 -0
  121. package/{lib/locales/ja-JP.js → src/locales/ja-JP.ts} +59 -17
  122. package/src/locales/types.ts +156 -0
  123. package/{lib/locales/zh-CN.js → src/locales/zh-CN.ts} +21 -38
  124. package/src/parser/ConstraintParser.ts +101 -0
  125. package/src/parser/DslParser.ts +470 -0
  126. package/src/parser/SchemaCompiler.ts +66 -0
  127. package/src/parser/TypeRegistry.ts +250 -0
  128. package/src/parser/index.ts +6 -0
  129. package/src/plugins/custom-format.ts +126 -0
  130. package/src/plugins/custom-type-example.ts +108 -0
  131. package/src/plugins/custom-validator.ts +140 -0
  132. package/src/types/conditional.ts +28 -0
  133. package/src/types/config.ts +59 -0
  134. package/src/types/dsl.ts +131 -0
  135. package/src/types/error.ts +60 -0
  136. package/src/types/index.ts +17 -0
  137. package/src/types/infer.ts +128 -0
  138. package/src/types/plugin.ts +58 -0
  139. package/src/types/safe-regex.d.ts +9 -0
  140. package/src/types/schema.ts +66 -0
  141. package/src/types/validate.ts +71 -0
  142. package/src/utils/SchemaHelper.ts +196 -0
  143. package/src/utils/SchemaUtils.ts +346 -0
  144. package/src/utils/TypeConverter.ts +215 -0
  145. package/src/utils/index.ts +10 -0
  146. package/src/validators/CustomKeywords.ts +477 -0
  147. package/.eslintignore +0 -11
  148. package/.eslintrc.json +0 -27
  149. package/CONTRIBUTING.md +0 -368
  150. package/STATUS.md +0 -491
  151. package/changelogs/v1.0.0.md +0 -328
  152. package/changelogs/v1.0.9.md +0 -367
  153. package/changelogs/v1.1.0.md +0 -389
  154. package/changelogs/v1.1.1.md +0 -308
  155. package/changelogs/v1.1.2.md +0 -183
  156. package/changelogs/v1.1.3.md +0 -161
  157. package/changelogs/v1.1.4.md +0 -432
  158. package/changelogs/v1.1.5.md +0 -493
  159. package/changelogs/v1.1.6.md +0 -211
  160. package/changelogs/v1.1.8.md +0 -376
  161. package/changelogs/v1.2.3.md +0 -124
  162. package/docs/INDEX.md +0 -252
  163. package/docs/issues-resolved-summary.md +0 -196
  164. package/docs/performance-benchmark-report.md +0 -179
  165. package/docs/performance-quick-reference.md +0 -123
  166. package/docs/user-questions-answered.md +0 -353
  167. package/docs/validation-rules-v1.0.2.md +0 -1608
  168. package/examples/README.md +0 -81
  169. package/examples/array-dsl-example.js +0 -227
  170. package/examples/conditional-example.js +0 -288
  171. package/examples/conditional-non-object.js +0 -129
  172. package/examples/conditional-validate-example.js +0 -321
  173. package/examples/custom-extension.js +0 -85
  174. package/examples/dsl-match-example.js +0 -74
  175. package/examples/dsl-style.js +0 -118
  176. package/examples/dynamic-locale-configuration.js +0 -348
  177. package/examples/dynamic-locale-example.js +0 -287
  178. package/examples/enum.examples.js +0 -324
  179. package/examples/export-demo.js +0 -130
  180. package/examples/express-integration.js +0 -376
  181. package/examples/i18n-error-handling-complete.js +0 -381
  182. package/examples/i18n-error-handling-quickstart.md +0 -0
  183. package/examples/i18n-error.examples.js +0 -181
  184. package/examples/i18n-full-demo.js +0 -301
  185. package/examples/i18n-memory-safety.examples.js +0 -268
  186. package/examples/markdown-export.js +0 -71
  187. package/examples/middleware-usage.js +0 -93
  188. package/examples/new-features-comparison.js +0 -315
  189. package/examples/password-reset/README.md +0 -153
  190. package/examples/password-reset/schema.js +0 -26
  191. package/examples/password-reset/test.js +0 -101
  192. package/examples/plugin-system.examples.js +0 -205
  193. package/examples/schema-utils-chaining.examples.js +0 -250
  194. package/examples/simple-example.js +0 -122
  195. package/examples/slug.examples.js +0 -179
  196. package/examples/string-extensions.js +0 -297
  197. package/examples/union-type-example.js +0 -127
  198. package/examples/union-types-example.js +0 -77
  199. package/examples/user-registration/README.md +0 -156
  200. package/examples/user-registration/routes.js +0 -92
  201. package/examples/user-registration/schema.js +0 -150
  202. package/examples/user-registration/server.js +0 -74
  203. package/index.d.ts +0 -3540
  204. package/index.js +0 -457
  205. package/index.mjs +0 -60
  206. package/lib/adapters/DslAdapter.js +0 -871
  207. package/lib/adapters/index.js +0 -20
  208. package/lib/config/constants.js +0 -286
  209. package/lib/config/patterns/common.js +0 -47
  210. package/lib/config/patterns/creditCard.js +0 -9
  211. package/lib/config/patterns/idCard.js +0 -9
  212. package/lib/config/patterns/index.js +0 -9
  213. package/lib/config/patterns/licensePlate.js +0 -4
  214. package/lib/config/patterns/passport.js +0 -4
  215. package/lib/config/patterns/phone.js +0 -9
  216. package/lib/config/patterns/postalCode.js +0 -5
  217. package/lib/core/CacheManager.js +0 -376
  218. package/lib/core/ConditionalBuilder.js +0 -503
  219. package/lib/core/DslBuilder.js +0 -1400
  220. package/lib/core/ErrorCodes.js +0 -233
  221. package/lib/core/ErrorFormatter.js +0 -445
  222. package/lib/core/JSONSchemaCore.js +0 -347
  223. package/lib/core/Locale.js +0 -130
  224. package/lib/core/MessageTemplate.js +0 -98
  225. package/lib/core/PluginManager.js +0 -448
  226. package/lib/core/StringExtensions.js +0 -240
  227. package/lib/core/Validator.js +0 -654
  228. package/lib/errors/I18nError.js +0 -328
  229. package/lib/errors/ValidationError.js +0 -191
  230. package/lib/exporters/MarkdownExporter.js +0 -420
  231. package/lib/exporters/MongoDBExporter.js +0 -162
  232. package/lib/exporters/MySQLExporter.js +0 -212
  233. package/lib/exporters/PostgreSQLExporter.js +0 -289
  234. package/lib/exporters/index.js +0 -24
  235. package/lib/locales/index.js +0 -8
  236. package/lib/utils/LRUCache.js +0 -174
  237. package/lib/utils/SchemaHelper.js +0 -240
  238. package/lib/utils/SchemaUtils.js +0 -445
  239. package/lib/utils/TypeConverter.js +0 -245
  240. package/lib/utils/index.js +0 -13
  241. package/lib/validators/CustomKeywords.js +0 -616
  242. package/lib/validators/index.js +0 -11
@@ -0,0 +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
+ }
@@ -0,0 +1,28 @@
1
+ import type { JSONSchema } from '../types/schema.js'
2
+
3
+ export const CONDITIONAL_RUNTIME_STATE: unique symbol = Symbol('schema-dsl.conditionalRuntimeState')
4
+
5
+ export interface ConditionalRuntimeState {
6
+ conditions: unknown[]
7
+ elseSchema: string | JSONSchema | null | undefined
8
+ evaluateCondition: (conditionObj: unknown, data: unknown) => {
9
+ result: boolean
10
+ failedMessage?: string | null
11
+ requirementFailed?: boolean
12
+ }
13
+ }
14
+
15
+ export type ConditionalRuntimeSchema = JSONSchema & {
16
+ [CONDITIONAL_RUNTIME_STATE]?: ConditionalRuntimeState
17
+ }
18
+
19
+ export function attachConditionalRuntime(schema: JSONSchema, state: ConditionalRuntimeState): ConditionalRuntimeSchema {
20
+ Object.defineProperty(schema, CONDITIONAL_RUNTIME_STATE, {
21
+ value: state,
22
+ enumerable: false,
23
+ configurable: false,
24
+ writable: false,
25
+ })
26
+
27
+ return schema as ConditionalRuntimeSchema
28
+ }
@@ -0,0 +1,255 @@
1
+ import type { JSONSchema } from '../types/schema.js'
2
+ import type { ValidateOptions, ValidationErrorItem, ValidationResult } from '../types/validate.js'
3
+ import type { DslDefinition } from '../types/dsl.js'
4
+ import { DslParser } from '../parser/DslParser.js'
5
+ import { Locale } from './Locale.js'
6
+ import { CONDITIONAL_RUNTIME_STATE, type ConditionalRuntimeState } from './ConditionalRuntime.js'
7
+
8
+ const EMPTY_ERRORS: ValidationErrorItem[] = []
9
+
10
+ export type ConditionalInternalSchema = JSONSchema & {
11
+ _required?: boolean
12
+ _label?: string
13
+ _customMessages?: Record<string, string>
14
+ _isConditional?: boolean
15
+ _runtimeOnlyConditional?: boolean
16
+ conditions?: Array<{ action?: string; message?: string; then?: unknown }>
17
+ _evaluateCondition?: (cond: unknown, data: unknown) => { result: boolean; failedMessage?: string; requirementFailed?: boolean }
18
+ else?: unknown
19
+ [CONDITIONAL_RUNTIME_STATE]?: ConditionalRuntimeState
20
+ }
21
+
22
+ interface ConditionalValidatorHooks {
23
+ validateSchema<T>(schema: JSONSchema, data: T, options: ValidateOptions): ValidationResult<T>
24
+ internalError<T>(error: unknown, data: T): ValidationResult<T>
25
+ }
26
+
27
+ export class ConditionalValidator {
28
+ constructor(private readonly hooks: ConditionalValidatorHooks) { }
29
+
30
+ hasAnyConditional(schema: ConditionalInternalSchema): boolean {
31
+ if (!schema.properties) return false
32
+ return Object.values(schema.properties).some((fieldSchema) => {
33
+ const fs = fieldSchema as ConditionalInternalSchema
34
+ if (fs._isConditional) return true
35
+ if (fs.properties) return this.hasAnyConditional(fs)
36
+ return false
37
+ })
38
+ }
39
+
40
+ validateWithConditionals<T>(
41
+ schema: ConditionalInternalSchema,
42
+ data: T,
43
+ options: ValidateOptions,
44
+ rootData?: Record<string, unknown>
45
+ ): ValidationResult<T> {
46
+ const errors: ValidationErrorItem[] = []
47
+ const effectiveRoot = rootData ?? (data as Record<string, unknown>)
48
+ const cleanSchema = JSON.parse(JSON.stringify(schema)) as ConditionalInternalSchema
49
+ const conditionalFields: Record<string, ConditionalInternalSchema> = {}
50
+ const nestedObjectFields: Record<string, ConditionalInternalSchema> = {}
51
+
52
+ for (const [fieldName, fieldSchema] of Object.entries(schema.properties ?? {})) {
53
+ const fs = fieldSchema as ConditionalInternalSchema
54
+ if (fs._isConditional) {
55
+ conditionalFields[fieldName] = fs
56
+ delete cleanSchema.properties?.[fieldName]
57
+
58
+ if (cleanSchema.required) {
59
+ cleanSchema.required = cleanSchema.required.filter(r => r !== fieldName)
60
+ }
61
+ } else if (fs.properties && this.hasAnyConditional(fs)) {
62
+ nestedObjectFields[fieldName] = fs
63
+ delete cleanSchema.properties?.[fieldName]
64
+ }
65
+ }
66
+
67
+ const baseResult = this.hooks.validateSchema(cleanSchema, data, options)
68
+ if (!baseResult.valid) {
69
+ errors.push(...(baseResult.errors ?? []))
70
+ }
71
+
72
+ for (const [fieldName, conditionalSchema] of Object.entries(conditionalFields)) {
73
+ const dataRecord = data as Record<string, unknown>
74
+ const fieldResult = this.validateConditional(conditionalSchema, effectiveRoot, fieldName, dataRecord[fieldName], options)
75
+
76
+ if (!fieldResult.valid) {
77
+ for (const err of (fieldResult.errors ?? [])) {
78
+ const errPath = (!err.path || err.path === 'value') ? fieldName : err.path
79
+ errors.push({ ...err, path: errPath, field: errPath })
80
+ }
81
+ }
82
+ }
83
+
84
+ for (const [fieldName, nestedSchema] of Object.entries(nestedObjectFields)) {
85
+ const dataRecord = data as Record<string, unknown>
86
+ const nestedData = dataRecord[fieldName]
87
+
88
+ if (nestedData === undefined || nestedData === null) {
89
+ const partialSchema = JSON.parse(JSON.stringify(schema)) as ConditionalInternalSchema
90
+ partialSchema.properties = { [fieldName]: nestedSchema }
91
+ partialSchema.required = (schema.required ?? []).filter(r => r === fieldName)
92
+ const partialResult = this.hooks.validateSchema(partialSchema, data, options)
93
+ if (!partialResult.valid) {
94
+ errors.push(...(partialResult.errors ?? []))
95
+ }
96
+ continue
97
+ }
98
+
99
+ const nestedResult = this.validateWithConditionals(nestedSchema, nestedData, options, effectiveRoot)
100
+ if (!nestedResult.valid) {
101
+ for (const err of (nestedResult.errors ?? [])) {
102
+ const errPath = err.path ? `${fieldName}/${err.path}` : fieldName
103
+ errors.push({ ...err, path: errPath, field: errPath })
104
+ }
105
+ }
106
+ }
107
+
108
+ if (errors.length === 0) return { valid: true, data, errors: EMPTY_ERRORS }
109
+ return { valid: false, data, errors, errorMessage: errors[0]?.message }
110
+ }
111
+
112
+ validateConditional<T>(
113
+ conditionalSchema: ConditionalInternalSchema,
114
+ data: Record<string, unknown>,
115
+ fieldName: string | null,
116
+ fieldValue: T,
117
+ options: ValidateOptions
118
+ ): ValidationResult<T> {
119
+ const locale = options.locale ?? Locale.getLocale()
120
+ const runtimeState = conditionalSchema[CONDITIONAL_RUNTIME_STATE]
121
+ const conditions = (runtimeState?.conditions ?? conditionalSchema.conditions ?? []) as Array<{ action?: string; message?: string; then?: unknown; type?: string }>
122
+
123
+ if (conditions.length === 0 && conditionalSchema._runtimeOnlyConditional) {
124
+ return {
125
+ valid: false,
126
+ data: fieldValue,
127
+ errors: [{
128
+ message: '[schema-dsl] Function-based conditional schemas are runtime-only and cannot be restored from JSON serialization.',
129
+ path: '',
130
+ keyword: 'conditional',
131
+ params: {},
132
+ }],
133
+ errorMessage: '[schema-dsl] Function-based conditional schemas are runtime-only and cannot be restored from JSON serialization.',
134
+ }
135
+ }
136
+
137
+ try {
138
+ for (const cond of conditions) {
139
+ const evaluation = runtimeState?.evaluateCondition(cond, data)
140
+ ?? conditionalSchema._evaluateCondition?.(cond, data)
141
+ ?? { result: false }
142
+ const matched = evaluation.result
143
+
144
+ if (cond.action === 'throw') {
145
+ if (matched) {
146
+ const errorMsg = evaluation.failedMessage ?? cond.message ?? 'Conditional validation failed'
147
+ const message = Locale.getMessageText(errorMsg, (options.messages ?? {}) as Record<string, string>, locale)
148
+ return {
149
+ valid: false,
150
+ data: fieldValue,
151
+ errors: [{ message, path: '', keyword: 'conditional', params: { condition: (cond as Record<string, unknown>)['type'] } }],
152
+ errorMessage: message,
153
+ }
154
+ }
155
+ continue
156
+ }
157
+
158
+ if (matched) {
159
+ const thenSchema = (cond as Record<string, unknown>)['then']
160
+ if (thenSchema !== undefined && thenSchema !== null) {
161
+ return this.executeThenBranch(thenSchema, data, fieldValue, fieldName, options)
162
+ }
163
+ return { valid: true, data: fieldValue, errors: EMPTY_ERRORS }
164
+ }
165
+
166
+ if (evaluation.requirementFailed) {
167
+ const errorMsg = cond.message ?? 'Condition not met'
168
+ const message = Locale.getMessageText(errorMsg, (options.messages ?? {}) as Record<string, string>, locale)
169
+ return {
170
+ valid: false,
171
+ data: fieldValue,
172
+ errors: [{ message, path: '', keyword: 'conditional', params: {} }],
173
+ errorMessage: message,
174
+ }
175
+ }
176
+ }
177
+
178
+ const elseSchema = runtimeState ? runtimeState.elseSchema : conditionalSchema.else
179
+ if (elseSchema !== undefined) {
180
+ if (elseSchema === null) return { valid: true, data: fieldValue, errors: EMPTY_ERRORS }
181
+ return this.executeThenBranch(elseSchema, data, fieldValue, fieldName, options)
182
+ }
183
+
184
+ return { valid: true, data: fieldValue, errors: EMPTY_ERRORS }
185
+ } catch (error) {
186
+ return this.hooks.internalError(error, fieldValue)
187
+ }
188
+ }
189
+
190
+ private executeThenBranch<T>(
191
+ thenSchema: unknown,
192
+ data: Record<string, unknown>,
193
+ fieldValue: T,
194
+ fieldName: string | null,
195
+ options: ValidateOptions
196
+ ): ValidationResult<T> {
197
+ let resolved = thenSchema
198
+
199
+ if (typeof resolved === 'string') {
200
+ resolved = DslParser.parseString(resolved)
201
+ }
202
+
203
+ if (resolved !== null && typeof resolved === 'object') {
204
+ const obj = resolved as Record<string, unknown>
205
+ if (typeof obj['toSchema'] === 'function') {
206
+ resolved = (obj['toSchema'] as () => JSONSchema)()
207
+ }
208
+ }
209
+
210
+ const resolvedSchema = resolved as ConditionalInternalSchema
211
+ if (resolvedSchema?._isConditional) {
212
+ return this.validateConditional(resolvedSchema, data, fieldName, fieldValue, options)
213
+ }
214
+
215
+ if (resolved !== null && typeof resolved === 'object' && !Array.isArray(resolved)) {
216
+ const obj = resolved as Record<string, unknown>
217
+ if (obj['type'] === undefined && obj['oneOf'] === undefined && obj['anyOf'] === undefined && obj['allOf'] === undefined) {
218
+ resolved = DslParser.parseObject(resolved as DslDefinition)
219
+ }
220
+ }
221
+
222
+ return this.validateFieldValue(resolved as JSONSchema, fieldValue, options)
223
+ }
224
+
225
+ private validateFieldValue<T>(schema: JSONSchema, fieldValue: T, options: ValidateOptions): ValidationResult<T> {
226
+ const internalSchema = schema as ConditionalInternalSchema
227
+ const isRequired = internalSchema._required === true
228
+
229
+ if (!isRequired && (fieldValue === undefined || fieldValue === '')) {
230
+ return { valid: true, data: fieldValue, errors: EMPTY_ERRORS }
231
+ }
232
+
233
+ if (isRequired && fieldValue === undefined) {
234
+ const locale = options.locale ?? Locale.getLocale()
235
+ const label = internalSchema._label ?? ''
236
+ const customMsgs = internalSchema._customMessages ?? {}
237
+ const allMsgs = { ...(options.messages ?? {}), ...customMsgs } as Record<string, string>
238
+ let message: string
239
+ if (allMsgs['required']) {
240
+ message = Locale.getMessageText(allMsgs['required'], allMsgs, locale)
241
+ } else {
242
+ message = Locale.getMessageText('required', allMsgs, locale)
243
+ if (label) message = `${label} ${message}`
244
+ }
245
+ return {
246
+ valid: false,
247
+ data: fieldValue,
248
+ errors: [{ message, path: '', keyword: 'required', params: {} }],
249
+ errorMessage: message,
250
+ }
251
+ }
252
+
253
+ return this.hooks.validateSchema(schema, fieldValue, options)
254
+ }
255
+ }