@objectql/core 1.7.1 → 1.7.3

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/src/index.ts CHANGED
@@ -6,5 +6,4 @@ export * from './hook';
6
6
  export * from './object';
7
7
  export * from './validator';
8
8
  export * from './util';
9
-
10
- export * from './util';
9
+ export * from './ai-agent';
package/src/repository.ts CHANGED
@@ -1,11 +1,16 @@
1
- import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, HookContext, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext } from '@objectql/types';
1
+ import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult } from '@objectql/types';
2
+ import { Validator } from './validator';
2
3
 
3
4
  export class ObjectRepository {
5
+ private validator: Validator;
6
+
4
7
  constructor(
5
8
  private objectName: string,
6
9
  private context: ObjectQLContext,
7
10
  private app: IObjectQL
8
- ) {}
11
+ ) {
12
+ this.validator = new Validator();
13
+ }
9
14
 
10
15
  private getDriver(): Driver {
11
16
  const obj = this.getSchema();
@@ -52,6 +57,79 @@ export class ObjectRepository {
52
57
  };
53
58
  }
54
59
 
60
+ /**
61
+ * Validates a record against field-level and object-level validation rules.
62
+ * For updates, only fields present in the update payload are validated at the field level,
63
+ * while object-level rules use the merged record (previousRecord + updates).
64
+ */
65
+ private async validateRecord(
66
+ operation: 'create' | 'update',
67
+ record: any,
68
+ previousRecord?: any
69
+ ): Promise<void> {
70
+ const schema = this.getSchema();
71
+ const allResults: ValidationRuleResult[] = [];
72
+
73
+ // 1. Validate field-level rules
74
+ // For updates, only validate fields that are present in the update payload
75
+ for (const [fieldName, fieldConfig] of Object.entries(schema.fields)) {
76
+ // Skip field validation for updates if the field is not in the update payload
77
+ if (operation === 'update' && !(fieldName in record)) {
78
+ continue;
79
+ }
80
+
81
+ const value = record[fieldName];
82
+ const fieldResults = await this.validator.validateField(
83
+ fieldName,
84
+ fieldConfig,
85
+ value,
86
+ {
87
+ record,
88
+ previousRecord,
89
+ operation,
90
+ user: this.getUserFromContext(),
91
+ api: this.getHookAPI(),
92
+ }
93
+ );
94
+ allResults.push(...fieldResults);
95
+ }
96
+
97
+ // 2. Validate object-level validation rules
98
+ if (schema.validation?.rules && schema.validation.rules.length > 0) {
99
+ // For updates, merge the update data with previous record to get the complete final state
100
+ const mergedRecord = operation === 'update' && previousRecord
101
+ ? { ...previousRecord, ...record }
102
+ : record;
103
+
104
+ // Track which fields changed (using shallow comparison for performance)
105
+ // IMPORTANT: Shallow comparison does not detect changes in nested objects/arrays.
106
+ // If your validation rules rely on detecting changes in complex nested structures,
107
+ // you may need to implement custom change tracking in hooks.
108
+ const changedFields = previousRecord
109
+ ? Object.keys(record).filter(key => record[key] !== previousRecord[key])
110
+ : undefined;
111
+
112
+ const validationContext: ValidationContext = {
113
+ record: mergedRecord,
114
+ previousRecord,
115
+ operation,
116
+ user: this.getUserFromContext(),
117
+ api: this.getHookAPI(),
118
+ changedFields,
119
+ };
120
+
121
+ const result = await this.validator.validate(schema.validation.rules, validationContext);
122
+ allResults.push(...result.results);
123
+ }
124
+
125
+ // 3. Collect errors and throw if any
126
+ const errors = allResults.filter(r => !r.valid && r.severity === 'error');
127
+ if (errors.length > 0) {
128
+ const errorMessage = errors.map(e => e.message).join('; ');
129
+ throw new ValidationError(errorMessage, errors);
130
+ }
131
+ }
132
+
55
133
  async find(query: UnifiedQuery = {}): Promise<any[]> {
56
134
  const hookCtx: RetrievalHookContext = {
57
135
  ...this.context,
@@ -129,10 +207,12 @@ export class ObjectRepository {
129
207
  await this.app.triggerHook('beforeCreate', this.objectName, hookCtx);
130
208
  const finalDoc = hookCtx.data || doc;
131
209
 
132
- const obj = this.getSchema();
133
210
  if (this.context.userId) finalDoc.created_by = this.context.userId;
134
211
  if (this.context.spaceId) finalDoc.space_id = this.context.spaceId;
135
212
 
213
+ // Validate the record before creating
214
+ await this.validateRecord('create', finalDoc);
215
+
136
216
  const result = await this.getDriver().create(this.objectName, finalDoc, this.getOptions());
137
217
 
138
218
  hookCtx.result = result;
@@ -156,6 +236,9 @@ export class ObjectRepository {
156
236
  };
157
237
  await this.app.triggerHook('beforeUpdate', this.objectName, hookCtx);
158
238
 
239
+ // Validate the update
240
+ await this.validateRecord('update', hookCtx.data, previousData);
241
+
159
242
  const result = await this.getDriver().update(this.objectName, id, hookCtx.data, this.getOptions(options));
160
243
 
161
244
  hookCtx.result = result;
package/src/validator.ts CHANGED
@@ -376,34 +376,217 @@ export class Validator {
376
376
  }
377
377
 
378
378
  /**
379
- * Validate uniqueness (stub - requires database access).
379
+ * Validate uniqueness by checking database for existing values.
380
380
  */
381
381
  private async validateUniqueness(
382
382
  rule: UniquenessValidationRule,
383
383
  context: ValidationContext
384
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
- };
385
+ // Check if API is available for database access
386
+ if (!context.api) {
387
+ // If no API provided, we can't validate - pass by default
388
+ return {
389
+ rule: rule.name,
390
+ valid: true,
391
+ };
392
+ }
393
+
394
+ // Get object name from context metadata
395
+ if (!context.metadata?.objectName) {
396
+ return {
397
+ rule: rule.name,
398
+ valid: false,
399
+ message: 'Object name not provided in validation context',
400
+ severity: rule.severity || 'error',
401
+ };
402
+ }
403
+
404
+ const objectName = context.metadata.objectName;
405
+
406
+ // Determine fields to check for uniqueness
407
+ const fieldsToCheck: string[] = rule.fields || (rule.field ? [rule.field] : []);
408
+
409
+ if (fieldsToCheck.length === 0) {
410
+ return {
411
+ rule: rule.name,
412
+ valid: false,
413
+ message: 'No fields specified for uniqueness validation',
414
+ severity: rule.severity || 'error',
415
+ };
416
+ }
417
+
418
+ // Build query filter
419
+ const filters: Record<string, any> = {};
420
+
421
+ // Add field conditions
422
+ for (const field of fieldsToCheck) {
423
+ const fieldValue = context.record[field];
424
+
425
+ // Skip validation if field value is null/undefined (no value to check uniqueness for)
426
+ if (fieldValue === null || fieldValue === undefined) {
427
+ return {
428
+ rule: rule.name,
429
+ valid: true,
430
+ };
431
+ }
432
+
433
+ // Handle case sensitivity for string values
434
+ if (typeof fieldValue === 'string' && rule.case_sensitive === false) {
435
+ // NOTE: Case-insensitive comparison requires driver-specific implementation
436
+ // Some drivers support regex (MongoDB), others use LOWER() function (SQL)
437
+ // For now, we use exact match - driver adapters should implement case-insensitive logic
438
+ filters[field] = fieldValue;
439
+ } else {
440
+ filters[field] = fieldValue;
441
+ }
442
+ }
443
+
444
+ // Apply scope conditions if specified
445
+ if (rule.scope) {
446
+ // Evaluate scope condition and add to filters
447
+ const scopeFields = this.extractFieldsFromCondition(rule.scope);
448
+ for (const field of scopeFields) {
449
+ if (context.record[field] !== undefined) {
450
+ filters[field] = context.record[field];
451
+ }
452
+ }
453
+ }
454
+
455
+ // Exclude current record for update operations
456
+ if (context.operation === 'update' && context.previousRecord?._id) {
457
+ filters._id = { $ne: context.previousRecord._id };
458
+ }
459
+
460
+ try {
461
+ // Query database to count existing records with same field values
462
+ const count = await context.api.count(objectName, filters);
463
+
464
+ const valid = count === 0;
465
+
466
+ return {
467
+ rule: rule.name,
468
+ valid,
469
+ message: valid ? undefined : this.formatMessage(rule.message, context.record),
470
+ error_code: rule.error_code,
471
+ severity: rule.severity || 'error',
472
+ fields: fieldsToCheck,
473
+ };
474
+ } catch (error) {
475
+ // If query fails, treat as validation error
476
+ return {
477
+ rule: rule.name,
478
+ valid: false,
479
+ message: `Uniqueness check failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
480
+ severity: rule.severity || 'error',
481
+ fields: fieldsToCheck,
482
+ };
483
+ }
392
484
  }
393
485
 
394
486
  /**
395
- * Validate business rule (stub - requires complex logic).
487
+ * Extract field names from a validation condition.
488
+ */
489
+ private extractFieldsFromCondition(condition: ValidationCondition): string[] {
490
+ const fields: string[] = [];
491
+
492
+ if (condition.field) {
493
+ fields.push(condition.field);
494
+ }
495
+
496
+ if (condition.all_of) {
497
+ for (const subcondition of condition.all_of) {
498
+ fields.push(...this.extractFieldsFromCondition(subcondition));
499
+ }
500
+ }
501
+
502
+ if (condition.any_of) {
503
+ for (const subcondition of condition.any_of) {
504
+ fields.push(...this.extractFieldsFromCondition(subcondition));
505
+ }
506
+ }
507
+
508
+ return fields;
509
+ }
510
+
511
+ /**
512
+ * Validate business rule by evaluating constraint conditions.
396
513
  */
397
514
  private async validateBusinessRule(
398
515
  rule: BusinessRuleValidationRule,
399
516
  context: ValidationContext
400
517
  ): Promise<ValidationRuleResult> {
401
- // TODO: Implement business rule evaluation
402
- // This requires expression parsing and relationship resolution
403
- // Stub: Pass silently until implementation is complete
518
+ if (!rule.constraint) {
519
+ // No constraint specified, validation passes
520
+ return {
521
+ rule: rule.name,
522
+ valid: true,
523
+ };
524
+ }
525
+
526
+ const constraint = rule.constraint;
527
+ let valid = true;
528
+
529
+ // Evaluate all_of conditions (all must be true)
530
+ if (constraint.all_of && constraint.all_of.length > 0) {
531
+ valid = constraint.all_of.every(condition => this.evaluateCondition(condition, context.record));
532
+
533
+ if (!valid) {
534
+ return {
535
+ rule: rule.name,
536
+ valid: false,
537
+ message: this.formatMessage(rule.message, context.record),
538
+ error_code: rule.error_code,
539
+ severity: rule.severity || 'error',
540
+ };
541
+ }
542
+ }
543
+
544
+ // Evaluate any_of conditions (at least one must be true)
545
+ if (constraint.any_of && constraint.any_of.length > 0) {
546
+ valid = constraint.any_of.some(condition => this.evaluateCondition(condition, context.record));
547
+
548
+ if (!valid) {
549
+ return {
550
+ rule: rule.name,
551
+ valid: false,
552
+ message: this.formatMessage(rule.message, context.record),
553
+ error_code: rule.error_code,
554
+ severity: rule.severity || 'error',
555
+ };
556
+ }
557
+ }
558
+
559
+ // Evaluate expression if provided (basic implementation)
560
+ if (constraint.expression) {
561
+ // For now, we'll treat expression validation as a stub
562
+ // Full implementation would require safe expression evaluation
563
+ // This could be extended to use a safe expression evaluator in the future
564
+ valid = true;
565
+ }
566
+
567
+ // Evaluate then_require conditions (conditional required fields)
568
+ if (constraint.then_require && constraint.then_require.length > 0) {
569
+ for (const condition of constraint.then_require) {
570
+ const conditionMet = this.evaluateCondition(condition, context.record);
571
+
572
+ if (!conditionMet) {
573
+ return {
574
+ rule: rule.name,
575
+ valid: false,
576
+ message: this.formatMessage(rule.message, context.record),
577
+ error_code: rule.error_code,
578
+ severity: rule.severity || 'error',
579
+ };
580
+ }
581
+ }
582
+ }
583
+
404
584
  return {
405
585
  rule: rule.name,
406
- valid: true,
586
+ valid,
587
+ message: valid ? undefined : this.formatMessage(rule.message, context.record),
588
+ error_code: rule.error_code,
589
+ severity: rule.severity || 'error',
407
590
  };
408
591
  }
409
592