@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/CHANGELOG.md +16 -0
- package/LICENSE +118 -21
- package/dist/ai-agent.d.ts +175 -0
- package/dist/ai-agent.js +746 -0
- package/dist/ai-agent.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/repository.d.ts +7 -0
- package/dist/repository.js +64 -1
- package/dist/repository.js.map +1 -1
- package/dist/validator.d.ts +6 -2
- package/dist/validator.js +172 -13
- package/dist/validator.js.map +1 -1
- package/package.json +7 -3
- package/src/ai-agent.ts +888 -0
- package/src/index.ts +1 -2
- package/src/repository.ts +86 -3
- package/src/validator.ts +196 -13
- package/test/repository-validation.test.ts +343 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts
CHANGED
package/src/repository.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery,
|
|
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
|
|
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
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
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
|
|