@objectql/core 1.4.0 → 1.5.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.
@@ -0,0 +1,553 @@
1
+ /**
2
+ * Validation engine for ObjectQL.
3
+ * Executes validation rules based on metadata configuration.
4
+ */
5
+
6
+ import {
7
+ AnyValidationRule,
8
+ ValidationContext,
9
+ ValidationResult,
10
+ ValidationRuleResult,
11
+ CrossFieldValidationRule,
12
+ StateMachineValidationRule,
13
+ UniquenessValidationRule,
14
+ BusinessRuleValidationRule,
15
+ CustomValidationRule,
16
+ FieldConfig,
17
+ ObjectDoc,
18
+ ValidationCondition,
19
+ ValidationOperator,
20
+ } from '@objectql/types';
21
+
22
+ /**
23
+ * Configuration options for the Validator.
24
+ */
25
+ export interface ValidatorOptions {
26
+ /** Preferred language for validation messages (default: 'en') */
27
+ language?: string;
28
+ /** Fallback languages if preferred language is not available */
29
+ languageFallback?: string[];
30
+ }
31
+
32
+ /**
33
+ * Validator class that executes validation rules.
34
+ */
35
+ export class Validator {
36
+ private options: ValidatorOptions;
37
+
38
+ constructor(options: ValidatorOptions = {}) {
39
+ this.options = {
40
+ language: options.language || 'en',
41
+ languageFallback: options.languageFallback || ['en', 'zh-CN'],
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Validate a record against a set of rules.
47
+ */
48
+ async validate(
49
+ rules: AnyValidationRule[],
50
+ context: ValidationContext
51
+ ): Promise<ValidationResult> {
52
+ const results: ValidationRuleResult[] = [];
53
+
54
+ for (const rule of rules) {
55
+ // Check if rule should be applied
56
+ if (!this.shouldApplyRule(rule, context)) {
57
+ continue;
58
+ }
59
+
60
+ // Execute validation based on rule type
61
+ let result: ValidationRuleResult;
62
+
63
+ try {
64
+ switch (rule.type) {
65
+ case 'cross_field':
66
+ result = await this.validateCrossField(rule as CrossFieldValidationRule, context);
67
+ break;
68
+ case 'state_machine':
69
+ result = await this.validateStateMachine(rule as StateMachineValidationRule, context);
70
+ break;
71
+ case 'unique':
72
+ result = await this.validateUniqueness(rule as UniquenessValidationRule, context);
73
+ break;
74
+ case 'business_rule':
75
+ result = await this.validateBusinessRule(rule as BusinessRuleValidationRule, context);
76
+ break;
77
+ case 'custom':
78
+ result = await this.validateCustom(rule as CustomValidationRule, context);
79
+ break;
80
+ default:
81
+ // Generic validation
82
+ result = {
83
+ rule: rule.name,
84
+ valid: true,
85
+ };
86
+ }
87
+ } catch (error) {
88
+ result = {
89
+ rule: rule.name,
90
+ valid: false,
91
+ message: error instanceof Error ? error.message : 'Validation error',
92
+ severity: rule.severity || 'error',
93
+ };
94
+ }
95
+
96
+ results.push(result);
97
+ }
98
+
99
+ // Categorize results
100
+ const errors = results.filter(r => !r.valid && r.severity === 'error');
101
+ const warnings = results.filter(r => !r.valid && r.severity === 'warning');
102
+ const info = results.filter(r => !r.valid && r.severity === 'info');
103
+
104
+ return {
105
+ valid: errors.length === 0,
106
+ results,
107
+ errors,
108
+ warnings,
109
+ info,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Validate field-level rules.
115
+ */
116
+ async validateField(
117
+ fieldName: string,
118
+ fieldConfig: FieldConfig,
119
+ value: any,
120
+ context: ValidationContext
121
+ ): Promise<ValidationRuleResult[]> {
122
+ const results: ValidationRuleResult[] = [];
123
+
124
+ // Required field validation
125
+ if (fieldConfig.required && (value === null || value === undefined || value === '')) {
126
+ results.push({
127
+ rule: `${fieldName}_required`,
128
+ valid: false,
129
+ message: fieldConfig.validation?.message || `${fieldConfig.label || fieldName} is required`,
130
+ severity: 'error',
131
+ fields: [fieldName],
132
+ });
133
+ }
134
+
135
+ // Skip further validation if value is empty and not required
136
+ if (value === null || value === undefined || value === '') {
137
+ return results;
138
+ }
139
+
140
+ // Type-specific validation
141
+ if (fieldConfig.validation) {
142
+ const validation = fieldConfig.validation;
143
+
144
+ // Email format
145
+ if (validation.format === 'email') {
146
+ // NOTE: This is a basic email validation regex. For production use,
147
+ // consider using a more comprehensive email validation library or regex
148
+ // that handles international domains, quoted strings, etc.
149
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
150
+ if (!emailRegex.test(value)) {
151
+ results.push({
152
+ rule: `${fieldName}_email_format`,
153
+ valid: false,
154
+ message: validation.message || 'Invalid email format',
155
+ severity: 'error',
156
+ fields: [fieldName],
157
+ });
158
+ }
159
+ }
160
+
161
+ // URL format
162
+ if (validation.format === 'url') {
163
+ try {
164
+ const url = new URL(value);
165
+ if (validation.protocols && !validation.protocols.includes(url.protocol.replace(':', ''))) {
166
+ results.push({
167
+ rule: `${fieldName}_url_protocol`,
168
+ valid: false,
169
+ message: validation.message || `URL must use one of: ${validation.protocols.join(', ')}`,
170
+ severity: 'error',
171
+ fields: [fieldName],
172
+ });
173
+ }
174
+ } catch {
175
+ results.push({
176
+ rule: `${fieldName}_url_format`,
177
+ valid: false,
178
+ message: validation.message || 'Invalid URL format',
179
+ severity: 'error',
180
+ fields: [fieldName],
181
+ });
182
+ }
183
+ }
184
+
185
+ // Pattern validation (supports both pattern and deprecated regex)
186
+ const patternValue = validation.pattern ?? validation.regex;
187
+ if (patternValue) {
188
+ try {
189
+ const pattern = new RegExp(patternValue);
190
+ if (!pattern.test(String(value))) {
191
+ results.push({
192
+ rule: `${fieldName}_pattern`,
193
+ valid: false,
194
+ message: validation.message || 'Value does not match required pattern',
195
+ severity: 'error',
196
+ fields: [fieldName],
197
+ });
198
+ }
199
+ } catch (error) {
200
+ results.push({
201
+ rule: `${fieldName}_pattern`,
202
+ valid: false,
203
+ message: `Invalid regex pattern: ${patternValue}`,
204
+ severity: 'error',
205
+ fields: [fieldName],
206
+ });
207
+ }
208
+ }
209
+
210
+ // Min/Max validation
211
+ if (validation.min !== undefined && value < validation.min) {
212
+ results.push({
213
+ rule: `${fieldName}_min`,
214
+ valid: false,
215
+ message: validation.message || `Value must be at least ${validation.min}`,
216
+ severity: 'error',
217
+ fields: [fieldName],
218
+ });
219
+ }
220
+
221
+ if (validation.max !== undefined && value > validation.max) {
222
+ results.push({
223
+ rule: `${fieldName}_max`,
224
+ valid: false,
225
+ message: validation.message || `Value must be at most ${validation.max}`,
226
+ severity: 'error',
227
+ fields: [fieldName],
228
+ });
229
+ }
230
+
231
+ // Length validation
232
+ const strValue = String(value);
233
+ if (validation.min_length !== undefined && strValue.length < validation.min_length) {
234
+ results.push({
235
+ rule: `${fieldName}_min_length`,
236
+ valid: false,
237
+ message: validation.message || `Must be at least ${validation.min_length} characters`,
238
+ severity: 'error',
239
+ fields: [fieldName],
240
+ });
241
+ }
242
+
243
+ if (validation.max_length !== undefined && strValue.length > validation.max_length) {
244
+ results.push({
245
+ rule: `${fieldName}_max_length`,
246
+ valid: false,
247
+ message: validation.message || `Must be at most ${validation.max_length} characters`,
248
+ severity: 'error',
249
+ fields: [fieldName],
250
+ });
251
+ }
252
+ }
253
+
254
+ // Legacy min/max from fieldConfig
255
+ if (fieldConfig.min !== undefined && value < fieldConfig.min) {
256
+ results.push({
257
+ rule: `${fieldName}_min`,
258
+ valid: false,
259
+ message: `Value must be at least ${fieldConfig.min}`,
260
+ severity: 'error',
261
+ fields: [fieldName],
262
+ });
263
+ }
264
+
265
+ if (fieldConfig.max !== undefined && value > fieldConfig.max) {
266
+ results.push({
267
+ rule: `${fieldName}_max`,
268
+ valid: false,
269
+ message: `Value must be at most ${fieldConfig.max}`,
270
+ severity: 'error',
271
+ fields: [fieldName],
272
+ });
273
+ }
274
+
275
+ return results;
276
+ }
277
+
278
+ /**
279
+ * Check if a rule should be applied based on triggers and conditions.
280
+ */
281
+ private shouldApplyRule(rule: AnyValidationRule, context: ValidationContext): boolean {
282
+ // Check trigger
283
+ if (rule.trigger && !rule.trigger.includes(context.operation)) {
284
+ return false;
285
+ }
286
+
287
+ // Check fields (for updates)
288
+ if (rule.fields && rule.fields.length > 0 && context.changedFields) {
289
+ const hasChangedField = rule.fields.some(f => context.changedFields!.includes(f));
290
+ if (!hasChangedField) {
291
+ return false;
292
+ }
293
+ }
294
+
295
+ // Check apply_when condition
296
+ if (rule.apply_when) {
297
+ return this.evaluateCondition(rule.apply_when, context.record);
298
+ }
299
+
300
+ return true;
301
+ }
302
+
303
+ /**
304
+ * Validate cross-field rule.
305
+ */
306
+ private async validateCrossField(
307
+ rule: CrossFieldValidationRule,
308
+ context: ValidationContext
309
+ ): Promise<ValidationRuleResult> {
310
+ if (!rule.rule) {
311
+ return { rule: rule.name, valid: true };
312
+ }
313
+
314
+ const valid = this.evaluateCondition(rule.rule, context.record);
315
+
316
+ return {
317
+ rule: rule.name,
318
+ valid,
319
+ message: valid ? undefined : this.formatMessage(rule.message, context.record),
320
+ error_code: rule.error_code,
321
+ severity: rule.severity || 'error',
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Validate state machine transitions.
327
+ */
328
+ private async validateStateMachine(
329
+ rule: StateMachineValidationRule,
330
+ context: ValidationContext
331
+ ): Promise<ValidationRuleResult> {
332
+ // Only validate on update
333
+ if (context.operation !== 'update' || !context.previousRecord) {
334
+ return { rule: rule.name, valid: true };
335
+ }
336
+
337
+ const oldState = context.previousRecord[rule.field];
338
+ const newState = context.record[rule.field];
339
+
340
+ // If state hasn't changed, validation passes
341
+ if (oldState === newState) {
342
+ return { rule: rule.name, valid: true };
343
+ }
344
+
345
+ // Check if transition is allowed
346
+ const transitions = rule.transitions?.[oldState];
347
+ if (!transitions) {
348
+ return {
349
+ rule: rule.name,
350
+ valid: false,
351
+ message: this.formatMessage(rule.message, { old_status: oldState, new_status: newState }),
352
+ error_code: rule.error_code,
353
+ severity: rule.severity || 'error',
354
+ fields: [rule.field],
355
+ };
356
+ }
357
+
358
+ // Handle both array and object format
359
+ let allowedNext: string[] = [];
360
+ if (Array.isArray(transitions)) {
361
+ allowedNext = transitions;
362
+ } else if (typeof transitions === 'object' && 'allowed_next' in transitions) {
363
+ allowedNext = transitions.allowed_next || [];
364
+ }
365
+
366
+ const isAllowed = allowedNext.includes(newState);
367
+
368
+ return {
369
+ rule: rule.name,
370
+ valid: isAllowed,
371
+ message: isAllowed ? undefined : this.formatMessage(rule.message, { old_status: oldState, new_status: newState }),
372
+ error_code: rule.error_code,
373
+ severity: rule.severity || 'error',
374
+ fields: [rule.field],
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Validate uniqueness (stub - requires database access).
380
+ */
381
+ private async validateUniqueness(
382
+ rule: UniquenessValidationRule,
383
+ context: ValidationContext
384
+ ): Promise<ValidationRuleResult> {
385
+ // TODO: Implement database query for uniqueness check
386
+ // This requires access to the data layer (driver/repository)
387
+ // Stub: Pass silently until implementation is complete
388
+ return {
389
+ rule: rule.name,
390
+ valid: true,
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Validate business rule (stub - requires complex logic).
396
+ */
397
+ private async validateBusinessRule(
398
+ rule: BusinessRuleValidationRule,
399
+ context: ValidationContext
400
+ ): Promise<ValidationRuleResult> {
401
+ // TODO: Implement business rule evaluation
402
+ // This requires expression parsing and relationship resolution
403
+ // Stub: Pass silently until implementation is complete
404
+ return {
405
+ rule: rule.name,
406
+ valid: true,
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Validate custom rule (stub - requires function execution).
412
+ */
413
+ private async validateCustom(
414
+ rule: CustomValidationRule,
415
+ context: ValidationContext
416
+ ): Promise<ValidationRuleResult> {
417
+ // TODO: Implement custom validator execution
418
+ // This requires safe function evaluation
419
+ // Stub: Pass silently until implementation is complete
420
+ return {
421
+ rule: rule.name,
422
+ valid: true,
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Evaluate a validation condition.
428
+ */
429
+ private evaluateCondition(condition: ValidationCondition, record: ObjectDoc): boolean {
430
+ // Handle logical operators
431
+ if (condition.all_of) {
432
+ return condition.all_of.every(c => this.evaluateCondition(c, record));
433
+ }
434
+
435
+ if (condition.any_of) {
436
+ return condition.any_of.some(c => this.evaluateCondition(c, record));
437
+ }
438
+
439
+ // Handle expression
440
+ if (condition.expression) {
441
+ // TODO: Implement safe expression evaluation
442
+ return true;
443
+ }
444
+
445
+ // Handle field comparison
446
+ if (condition.field && condition.operator !== undefined) {
447
+ const fieldValue = record[condition.field];
448
+ // Use compare_to if specified (cross-field comparison), otherwise use value
449
+ const compareValue = condition.compare_to !== undefined
450
+ ? record[condition.compare_to]
451
+ : condition.value;
452
+ return this.compareValues(fieldValue, condition.operator, compareValue);
453
+ }
454
+
455
+ return true;
456
+ }
457
+
458
+ /**
459
+ * Compare two values using an operator.
460
+ */
461
+ private compareValues(a: any, operator: ValidationOperator, b: any): boolean {
462
+ switch (operator) {
463
+ case '=':
464
+ return a === b;
465
+ case '!=':
466
+ return a !== b;
467
+ case '>':
468
+ return a > b;
469
+ case '>=':
470
+ return a >= b;
471
+ case '<':
472
+ return a < b;
473
+ case '<=':
474
+ return a <= b;
475
+ case 'in':
476
+ return Array.isArray(b) && b.includes(a);
477
+ case 'not_in':
478
+ return Array.isArray(b) && !b.includes(a);
479
+ case 'contains': {
480
+ if (a == null || b == null) {
481
+ return false;
482
+ }
483
+ const strA = String(a);
484
+ const strB = String(b);
485
+ return strA.includes(strB);
486
+ }
487
+ case 'not_contains': {
488
+ if (a == null || b == null) {
489
+ return false;
490
+ }
491
+ const strA = String(a);
492
+ const strB = String(b);
493
+ return !strA.includes(strB);
494
+ }
495
+ case 'starts_with': {
496
+ if (a == null || b == null) {
497
+ return false;
498
+ }
499
+ const strA = String(a);
500
+ const strB = String(b);
501
+ return strA.startsWith(strB);
502
+ }
503
+ case 'ends_with': {
504
+ if (a == null || b == null) {
505
+ return false;
506
+ }
507
+ const strA = String(a);
508
+ const strB = String(b);
509
+ return strA.endsWith(strB);
510
+ }
511
+ default:
512
+ return false;
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Format validation message with template variables.
518
+ */
519
+ private formatMessage(message: string | Record<string, string>, context: any): string {
520
+ // Handle i18n messages
521
+ if (typeof message === 'object') {
522
+ // Try preferred language first
523
+ const preferredLanguage = this.options.language ?? 'en';
524
+ let messageText = message[preferredLanguage];
525
+
526
+ // Try fallback languages if preferred not available
527
+ if (!messageText && this.options.languageFallback) {
528
+ for (const lang of this.options.languageFallback) {
529
+ if (message[lang]) {
530
+ messageText = message[lang];
531
+ break;
532
+ }
533
+ }
534
+ }
535
+
536
+ // Fallback to first available message
537
+ message = messageText || Object.values(message)[0];
538
+ }
539
+
540
+ // Replace template variables
541
+ return message.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (match, path) => {
542
+ const value = this.getNestedValue(context, path);
543
+ return value !== undefined ? String(value) : match;
544
+ });
545
+ }
546
+
547
+ /**
548
+ * Get nested value from object by path.
549
+ */
550
+ private getNestedValue(obj: any, path: string): any {
551
+ return path.split('.').reduce((current, key) => current?.[key], obj);
552
+ }
553
+ }
@@ -0,0 +1,124 @@
1
+ name: project
2
+ label: Project
3
+ description: Project object with validation rules
4
+
5
+ fields:
6
+ name:
7
+ type: text
8
+ label: Project Name
9
+ required: true
10
+ validation:
11
+ min_length: 3
12
+ max_length: 100
13
+ message: Project name must be between 3 and 100 characters
14
+ ai_context:
15
+ intent: Unique identifier for the project
16
+
17
+ description:
18
+ type: textarea
19
+ label: Description
20
+
21
+ status:
22
+ type: select
23
+ label: Status
24
+ required: true
25
+ defaultValue: planning
26
+ options:
27
+ - label: Planning
28
+ value: planning
29
+ - label: Active
30
+ value: active
31
+ - label: On Hold
32
+ value: on_hold
33
+ - label: Completed
34
+ value: completed
35
+ - label: Cancelled
36
+ value: cancelled
37
+ ai_context:
38
+ intent: Track project through its lifecycle
39
+ is_state_machine: true
40
+
41
+ budget:
42
+ type: currency
43
+ label: Budget
44
+ validation:
45
+ min: 0
46
+ max: 10000000
47
+ message: Budget must be between 0 and 10,000,000
48
+
49
+ start_date:
50
+ type: date
51
+ label: Start Date
52
+ required: true
53
+
54
+ end_date:
55
+ type: date
56
+ label: End Date
57
+
58
+ email:
59
+ type: email
60
+ label: Contact Email
61
+ validation:
62
+ format: email
63
+ message: Please enter a valid email address
64
+
65
+ website:
66
+ type: url
67
+ label: Website
68
+ validation:
69
+ format: url
70
+ protocols: [http, https]
71
+ message: Please enter a valid URL
72
+
73
+ validation:
74
+ ai_context:
75
+ intent: Ensure project data integrity and enforce business rules
76
+ validation_strategy: Fail fast with clear error messages
77
+
78
+ rules:
79
+ # Cross-field validation: End date must be after start date
80
+ - name: valid_date_range
81
+ type: cross_field
82
+ ai_context:
83
+ intent: Ensure timeline makes logical sense
84
+ business_rule: Projects cannot end before they start
85
+ error_impact: high
86
+ rule:
87
+ field: end_date
88
+ operator: ">="
89
+ compare_to: start_date
90
+ message: End date must be on or after start date
91
+ error_code: INVALID_DATE_RANGE
92
+
93
+ # State machine validation
94
+ - name: status_transition
95
+ type: state_machine
96
+ field: status
97
+ ai_context:
98
+ intent: Control valid status transitions throughout project lifecycle
99
+ business_rule: Projects follow a controlled workflow
100
+ transitions:
101
+ planning:
102
+ allowed_next: [active, cancelled]
103
+ ai_context:
104
+ rationale: Can start work or cancel before beginning
105
+ active:
106
+ allowed_next: [on_hold, completed, cancelled]
107
+ ai_context:
108
+ rationale: Can pause, finish, or cancel ongoing work
109
+ on_hold:
110
+ allowed_next: [active, cancelled]
111
+ ai_context:
112
+ rationale: Can resume or cancel paused projects
113
+ completed:
114
+ allowed_next: []
115
+ is_terminal: true
116
+ ai_context:
117
+ rationale: Finished projects cannot change state
118
+ cancelled:
119
+ allowed_next: []
120
+ is_terminal: true
121
+ ai_context:
122
+ rationale: Cancelled projects are final
123
+ message: "Invalid status transition from {{old_status}} to {{new_status}}"
124
+ error_code: INVALID_STATE_TRANSITION