@platecms/delta-validation 1.2.0 → 1.4.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 (29) hide show
  1. package/package.json +4 -4
  2. package/src/index.ts +8 -7
  3. package/src/lib/enums/validation-rule-update-impact.enum.ts +5 -0
  4. package/src/lib/helpers/fields-to-schema.spec.ts +184 -0
  5. package/src/lib/helpers/fields-to-schema.ts +141 -0
  6. package/src/lib/helpers/get-content-value-type-name.ts +26 -0
  7. package/src/lib/helpers/is-superset.helper.ts +4 -0
  8. package/src/lib/helpers/schema-builder.ts +5 -0
  9. package/src/lib/schemas/content-value.schema.ts +27 -2
  10. package/src/lib/schemas/object.schema.spec.ts +223 -0
  11. package/src/lib/schemas/object.schema.ts +106 -0
  12. package/src/lib/types/validation-schema.interface.ts +1 -1
  13. package/src/lib/validation-rules/allowed-values.validation-rule.spec.ts +25 -0
  14. package/src/lib/validation-rules/allowed-values.validation-rule.ts +15 -0
  15. package/src/lib/validation-rules/base.validation-rule.ts +21 -4
  16. package/src/lib/validation-rules/count.validation-rule.spec.ts +38 -0
  17. package/src/lib/validation-rules/count.validation-rule.ts +35 -0
  18. package/src/lib/validation-rules/date-between.validation-rule.spec.ts +83 -0
  19. package/src/lib/validation-rules/date-between.validation-rule.ts +15 -0
  20. package/src/lib/validation-rules/decimal-count.validation-rule.spec.ts +26 -0
  21. package/src/lib/validation-rules/decimal-count.validation-rule.ts +12 -0
  22. package/src/lib/validation-rules/number-between.validation-rule.spec.ts +44 -0
  23. package/src/lib/validation-rules/number-between.validation-rule.ts +14 -0
  24. package/src/lib/validation-rules/relatable-content-types.validation-rule.spec.ts +33 -8
  25. package/src/lib/validation-rules/relatable-content-types.validation-rule.ts +17 -0
  26. package/src/lib/validation-rules/string-format.validation-rule.spec.ts +47 -21
  27. package/src/lib/validation-rules/string-format.validation-rule.ts +14 -0
  28. package/src/lib/validation-rules/value-type.validation-rule.spec.ts +104 -74
  29. package/src/lib/validation-rules/value-type.validation-rule.ts +17 -2
@@ -0,0 +1,223 @@
1
+ import { PRN } from "@platecms/delta-plate-resource-notation";
2
+ import { RuleType } from "../enums/rule-types.enum";
3
+ import { NumberValidationSchema } from "./number.schema";
4
+ import { ObjectValidationSchema } from "./object.schema";
5
+ import { StringValidationSchema } from "./string.schema";
6
+
7
+ type TestObject = Record<string, unknown>;
8
+
9
+ describe("ObjectValidationSchema", () => {
10
+ let objectSchema: ObjectValidationSchema<TestObject>;
11
+
12
+ beforeEach(() => {
13
+ objectSchema = new ObjectValidationSchema<TestObject>();
14
+ });
15
+
16
+ describe("type checking", () => {
17
+ it("passes validation when value is an object", () => {
18
+ const validationResult = objectSchema.validate({ alpha: 1, beta: 2 }, { path: ["test"] });
19
+
20
+ expect(validationResult.isValid).toBe(true);
21
+ expect(validationResult.errors).toHaveLength(0);
22
+ });
23
+
24
+ it("fails validation when value is not an object", () => {
25
+ const validationResult = objectSchema.validate("not an object" as unknown as TestObject, {
26
+ path: ["test"],
27
+ });
28
+
29
+ expect(validationResult.isValid).toBe(false);
30
+ expect(validationResult.errors).toHaveLength(1);
31
+ expect(validationResult.errors[0].message).toBe("Value must be an object");
32
+ });
33
+
34
+ it("fails validation when value is an array", () => {
35
+ const validationResult = objectSchema.validate([1, 2, 3] as unknown as TestObject, {
36
+ path: ["test"],
37
+ });
38
+
39
+ expect(validationResult.isValid).toBe(false);
40
+ expect(validationResult.errors).toHaveLength(1);
41
+ expect(validationResult.errors[0].message).toBe("Value must be an object");
42
+ });
43
+
44
+ it("fails validation when value is a number", () => {
45
+ const validationResult = objectSchema.validate(42 as unknown as TestObject, {
46
+ path: ["test"],
47
+ });
48
+
49
+ expect(validationResult.isValid).toBe(false);
50
+ expect(validationResult.errors).toHaveLength(1);
51
+ expect(validationResult.errors[0].message).toBe("Value must be an object");
52
+ });
53
+
54
+ it("passes validation when value is null (allowed by default)", () => {
55
+ const validationResult = objectSchema.validate(null as unknown as TestObject, {
56
+ path: ["test"],
57
+ });
58
+
59
+ expect(validationResult.isValid).toBe(true);
60
+ expect(validationResult.errors).toHaveLength(0);
61
+ });
62
+
63
+ it("passes validation when value is undefined (allowed by default)", () => {
64
+ const validationResult = objectSchema.validate(undefined as unknown as TestObject, {
65
+ path: ["test"],
66
+ });
67
+
68
+ expect(validationResult.isValid).toBe(true);
69
+ expect(validationResult.errors).toHaveLength(0);
70
+ });
71
+ });
72
+
73
+ describe("count validation", () => {
74
+ it("passes validation when object keys count is within min and max bounds", () => {
75
+ objectSchema.min(2).max(4);
76
+
77
+ const validationResult = objectSchema.validate({ alpha: 1, beta: 2, gamma: 3 }, { path: ["test"] });
78
+
79
+ expect(validationResult.isValid).toBe(true);
80
+ expect(validationResult.errors).toHaveLength(0);
81
+ });
82
+
83
+ it("fails validation when object keys count is below minimum", () => {
84
+ objectSchema.min(3);
85
+
86
+ const validationResult = objectSchema.validate({ alpha: 1, beta: 2 }, { path: ["test"] });
87
+
88
+ expect(validationResult.isValid).toBe(false);
89
+ expect(validationResult.errors).toHaveLength(1);
90
+ expect(validationResult.errors[0].message).toContain("object keys");
91
+ });
92
+
93
+ it("fails validation when object keys count is above maximum", () => {
94
+ objectSchema.max(2);
95
+
96
+ const validationResult = objectSchema.validate({ alpha: 1, beta: 2, gamma: 3 }, { path: ["test"] });
97
+
98
+ expect(validationResult.isValid).toBe(false);
99
+ expect(validationResult.errors).toHaveLength(1);
100
+ expect(validationResult.errors[0].message).toContain("object keys");
101
+ });
102
+
103
+ it("passes validation when no min/max constraints are set", () => {
104
+ const validationResult = objectSchema.validate(
105
+ { alpha: 1, beta: 2, gamma: 3, delta: 4, epsilon: 5 },
106
+ { path: ["test"] },
107
+ );
108
+
109
+ expect(validationResult.isValid).toBe(true);
110
+ expect(validationResult.errors).toHaveLength(0);
111
+ });
112
+
113
+ it("handles empty object with min constraint", () => {
114
+ objectSchema.min(1);
115
+
116
+ const validationResult = objectSchema.validate({}, { path: ["test"] });
117
+
118
+ expect(validationResult.isValid).toBe(false);
119
+ expect(validationResult.errors).toHaveLength(1);
120
+ });
121
+
122
+ it("handles empty object with max constraint", () => {
123
+ objectSchema.max(2);
124
+
125
+ const validationResult = objectSchema.validate({}, { path: ["test"] });
126
+
127
+ expect(validationResult.isValid).toBe(true);
128
+ expect(validationResult.errors).toHaveLength(0);
129
+ });
130
+ });
131
+
132
+ describe("shape validation", () => {
133
+ it("validates object keys against shape schema", () => {
134
+ const stringValidationSchema = new StringValidationSchema();
135
+ objectSchema.of({ name: stringValidationSchema });
136
+
137
+ const validationResult = objectSchema.validate({ name: "John" }, { path: ["test"] });
138
+
139
+ expect(validationResult.isValid).toBe(true);
140
+ expect(validationResult.errors).toHaveLength(0);
141
+ });
142
+
143
+ it("fails when shape property fails validation", () => {
144
+ const stringValidationSchema = new StringValidationSchema();
145
+ objectSchema.of({ name: stringValidationSchema });
146
+
147
+ const validationResult = objectSchema.validate({ name: 123 }, { path: ["test"] });
148
+
149
+ expect(validationResult.isValid).toBe(false);
150
+ expect(validationResult.errors.length).toBeGreaterThan(0);
151
+ expect(validationResult.errors[0].path).toEqual(["test", "name"]);
152
+ });
153
+
154
+ it("does not validate shape when no shape is set", () => {
155
+ const valueWithArbitraryKeys: TestObject = {
156
+ name: "string",
157
+ count: 123,
158
+ flag: true,
159
+ };
160
+
161
+ const validationResult = objectSchema.validate(valueWithArbitraryKeys, {
162
+ path: ["test"],
163
+ });
164
+
165
+ expect(validationResult.isValid).toBe(true);
166
+ expect(validationResult.errors).toHaveLength(0);
167
+ });
168
+
169
+ it("validates nested shape with multiple keys", () => {
170
+ const stringValidationSchema = new StringValidationSchema();
171
+ const numberValidationSchema = new NumberValidationSchema();
172
+ objectSchema.of({ name: stringValidationSchema, age: numberValidationSchema });
173
+
174
+ const validationResult = objectSchema.validate({ name: "Jane", age: 30 }, { path: ["user"] });
175
+
176
+ expect(validationResult.isValid).toBe(true);
177
+ expect(validationResult.errors).toHaveLength(0);
178
+ });
179
+
180
+ it("collects errors for multiple invalid shape properties", () => {
181
+ const stringValidationSchema = new StringValidationSchema();
182
+ const numberValidationSchema = new NumberValidationSchema();
183
+ objectSchema.of({ name: stringValidationSchema, age: numberValidationSchema });
184
+
185
+ const validationResult = objectSchema.validate({ name: 123, age: "not a number" }, { path: ["user"] });
186
+
187
+ expect(validationResult.isValid).toBe(false);
188
+ expect(validationResult.errors).toHaveLength(2);
189
+ expect(validationResult.errors.map((error) => error.path)).toContainEqual(["user", "name"]);
190
+ expect(validationResult.errors.map((error) => error.path)).toContainEqual(["user", "age"]);
191
+ });
192
+ });
193
+
194
+ describe("integration with validation rules", () => {
195
+ it("uses validation rule for count validation", () => {
196
+ const countValidationRuleMock = {
197
+ ruleType: RuleType.COUNT,
198
+ settings: { min: 2, max: 4 },
199
+ id: "count-rule",
200
+ prn: PRN.fromString("prn:plate:-1:cms:validation-rule:1"),
201
+ };
202
+
203
+ const objectSchemaWithCountRule = new ObjectValidationSchema<TestObject>(countValidationRuleMock);
204
+ const validationResult = objectSchemaWithCountRule.validate({ alpha: 1, beta: 2, gamma: 3 }, { path: ["test"] });
205
+
206
+ expect(validationResult.isValid).toBe(true);
207
+ });
208
+ });
209
+
210
+ describe("method chaining", () => {
211
+ it("supports method chaining", () => {
212
+ const stringValidationSchema = new StringValidationSchema();
213
+
214
+ const validationResult = objectSchema
215
+ .min(1)
216
+ .max(5)
217
+ .of({ key: stringValidationSchema })
218
+ .validate({ key: "value" }, { path: ["test"] });
219
+
220
+ expect(validationResult.isValid).toBe(true);
221
+ });
222
+ });
223
+ });
@@ -0,0 +1,106 @@
1
+ import { RuleType } from "../enums/rule-types.enum";
2
+ import { validateWithFunction } from "../helpers/validate-with-function";
3
+ import { RuleInstance } from "../types/rule-instance.interface";
4
+ import { ValidationContext } from "../types/validation-context.interface";
5
+ import { ValidationErrorData } from "../types/validation-error-data.interface";
6
+ import { SchemaType, ValidationSchema } from "../types/validation-schema.interface";
7
+ import { CountValidationRuleSettings, validateKeysCount } from "../validation-rules/count.validation-rule";
8
+ import { BaseValidationSchema } from "./base.schema";
9
+
10
+ export type Shape = Record<string, ValidationSchema<SchemaType>>;
11
+
12
+ export class ObjectValidationSchema<T = unknown> extends BaseValidationSchema<T, ObjectValidationSchema<T>> {
13
+ public readonly type = "object";
14
+ private _shape?: Shape;
15
+ private _min?: number;
16
+ private _max?: number;
17
+
18
+ public of(shape: Shape): this {
19
+ this._shape = shape;
20
+ return this;
21
+ }
22
+
23
+ public min(min: number): this {
24
+ this._min = min;
25
+ return this;
26
+ }
27
+
28
+ public max(max: number): this {
29
+ this._max = max;
30
+ return this;
31
+ }
32
+
33
+ protected _typeCheck(value: unknown, path: string[]): ValidationErrorData[] {
34
+ if (typeof value === "object" && !Array.isArray(value)) {
35
+ return [];
36
+ }
37
+
38
+ return [
39
+ {
40
+ path,
41
+ message: this._message ?? "Value must be an object",
42
+ provided: value,
43
+ validationRule: {
44
+ ruleType: RuleType.VALUE_TYPE,
45
+ settings: {},
46
+ },
47
+ },
48
+ ];
49
+ }
50
+
51
+ protected _validateAgainstSchema(value: T, context: ValidationContext = {}): ValidationErrorData[] {
52
+ const errors: ValidationErrorData[] = [];
53
+
54
+ const itemsCountError = this._validateCount(value, context.path ?? []);
55
+ if (itemsCountError) {
56
+ errors.push(itemsCountError);
57
+ }
58
+
59
+ errors.push(...this._validateObject(value, context));
60
+
61
+ return errors;
62
+ }
63
+
64
+ private _validateCount(value: T, path: string[]): ValidationErrorData<CountValidationRuleSettings> | null {
65
+ if (this._min === undefined && this._max === undefined && this._validationRule === undefined) {
66
+ return null;
67
+ }
68
+
69
+ const validationRule = this._validationRule ?? {
70
+ ruleType: RuleType.COUNT,
71
+ settings: {
72
+ min: this._min,
73
+ max: this._max,
74
+ },
75
+ };
76
+
77
+ return validateWithFunction<CountValidationRuleSettings, T>(
78
+ value,
79
+ validationRule as RuleInstance<CountValidationRuleSettings>,
80
+ path,
81
+ this._message ?? "Object keys count validation failed",
82
+ validateKeysCount,
83
+ );
84
+ }
85
+
86
+ private _validateObject(value: T, context: ValidationContext): ValidationErrorData[] {
87
+ if (this._shape === undefined) {
88
+ return [];
89
+ }
90
+
91
+ const indexableValue = value as unknown as Record<string, unknown>;
92
+
93
+ const errors: ValidationErrorData[] = [];
94
+ for (const key of Object.keys(this._shape)) {
95
+ const result = this._shape[key].validate(indexableValue[key], {
96
+ ...context,
97
+ path: [...(context.path ?? []), key],
98
+ });
99
+ if (!result.isValid) {
100
+ errors.push(...result.errors);
101
+ }
102
+ }
103
+
104
+ return errors;
105
+ }
106
+ }
@@ -1,7 +1,7 @@
1
1
  import { ValidationContext } from "./validation-context.interface";
2
2
  import { ValidationResult } from "./validation-result.interface";
3
3
 
4
- export type SchemaType = "array" | "content-value" | "date" | "number" | "string" | "union";
4
+ export type SchemaType = "array" | "content-value" | "date" | "number" | "object" | "string" | "union";
5
5
 
6
6
  /**
7
7
  * TType represents the value type this schema validates.
@@ -1,6 +1,7 @@
1
1
  import { PRN } from "@platecms/delta-plate-resource-notation";
2
2
  import { ContentValueType } from "@platecms/delta-types";
3
3
  import { AllowedValuesValidationRule } from "./allowed-values.validation-rule";
4
+ import { ValidationRuleUpdateImpact } from "../enums/validation-rule-update-impact.enum";
4
5
 
5
6
  describe("AllowedValuesValidationRule", () => {
6
7
  let rulePrn: PRN;
@@ -54,4 +55,28 @@ describe("AllowedValuesValidationRule", () => {
54
55
  });
55
56
  });
56
57
  });
58
+
59
+ describe("classifyUpdate", () => {
60
+ let rule: AllowedValuesValidationRule;
61
+ const allowedValues = [1, "2", true, new Date("2020-01-01"), PRN.fromString("prn:plate:-1:cms:content-item:1")];
62
+
63
+ beforeEach(() => {
64
+ rule = new AllowedValuesValidationRule(rulePrn, { allowedValues });
65
+ });
66
+
67
+ it.each([
68
+ [ValidationRuleUpdateImpact.NONE, { allowedValues: [...allowedValues, "a"] }], // extending
69
+ [ValidationRuleUpdateImpact.NONE, { allowedValues: [...allowedValues] }], // unchanged
70
+ [ValidationRuleUpdateImpact.INVALIDATES, { allowedValues: ["a"] }], // tightening
71
+ ])("returns %s when the new allowed values are %s", (expected, newSettings) => {
72
+ expect(rule.classifyUpdate(newSettings)).toBe(expected);
73
+ });
74
+ });
75
+
76
+ describe("classifyCreate", () => {
77
+ it("returns INVALIDATES", () => {
78
+ const rule = new AllowedValuesValidationRule(rulePrn, { allowedValues: [] });
79
+ expect(rule.classifyCreate()).toBe(ValidationRuleUpdateImpact.INVALIDATES);
80
+ });
81
+ });
57
82
  });
@@ -5,6 +5,8 @@ import { RuleInstance } from "../types/rule-instance.interface";
5
5
  import { RuleType } from "../enums/rule-types.enum";
6
6
  import { BaseValidationRule, ContentValidationResultType } from "./base.validation-rule";
7
7
  import { validationFunctionFromValidateValue } from "./validation-rule-function.factory";
8
+ import { ValidationRuleUpdateImpact } from "../enums/validation-rule-update-impact.enum";
9
+ import { isSuperset } from "../helpers/is-superset.helper";
8
10
 
9
11
  export function validateValueAllowedValues(
10
12
  value: ContentValueType,
@@ -46,6 +48,19 @@ export class AllowedValuesValidationRule extends BaseValidationRule<AllowedValue
46
48
  public override validate(values: ContentValueType[]): ContentValidationResultType[] {
47
49
  return validateAllowedValues(values, this);
48
50
  }
51
+
52
+ public override classifyCreate(): ValidationRuleUpdateImpact {
53
+ return ValidationRuleUpdateImpact.INVALIDATES;
54
+ }
55
+
56
+ public override classifyUpdate(newSettings: AllowedValuesValidationRuleSettings): ValidationRuleUpdateImpact {
57
+ const oldAllowedValues = this.settings.allowedValues;
58
+ const newAllowedValues = newSettings.allowedValues;
59
+
60
+ return isSuperset(newAllowedValues, oldAllowedValues)
61
+ ? ValidationRuleUpdateImpact.NONE
62
+ : ValidationRuleUpdateImpact.INVALIDATES;
63
+ }
49
64
  }
50
65
 
51
66
  export interface AllowedValuesValidationRuleSettings {
@@ -1,8 +1,9 @@
1
1
  import { PRN } from "@platecms/delta-plate-resource-notation";
2
2
  import { ContentValueType } from "@platecms/delta-types";
3
+ import { RuleType } from "../enums/rule-types.enum";
4
+ import { ValidationRuleUpdateImpact } from "../enums/validation-rule-update-impact.enum";
3
5
  import { ValidationErrorDetails } from "../errors/validation-error-details";
4
6
  import { RuleInstance } from "../types/rule-instance.interface";
5
- import { RuleType } from "../enums/rule-types.enum";
6
7
 
7
8
  export type ContentValidationResultType<TRuleInstance extends RuleInstance<unknown> = RuleInstance<unknown>> =
8
9
  | ValidationErrorDetails<TRuleInstance>
@@ -29,7 +30,23 @@ export abstract class BaseValidationRule<TSettings> implements RuleInstance<TSet
29
30
  * and the result will be returned in the first element of the array. E.g. the CountValidationRule.
30
31
  * @param values ContentValueType[] to validate
31
32
  */
32
- public validate(_: ContentValueType[]): ContentValidationResultType[] {
33
- throw new Error("validate method must be overridden.");
34
- }
33
+ public abstract validate(_: ContentValueType[]): ContentValidationResultType[];
34
+
35
+ /**
36
+ * Classifies the impact of creating a validation rule.
37
+ * - {@link ValidationRuleUpdateImpact.INVALIDATES}: Some existing data may become invalid but can be fixed (e.g. adding constraints); set owners to draft.
38
+ * - {@link ValidationRuleUpdateImpact.BREAKS}: Only for VALUE_TYPE; all current values become invalid (e.g. adding type constraint); clear values and set owners to draft.
39
+ * @returns The impact of the create.
40
+ */
41
+ public abstract classifyCreate(): ValidationRuleUpdateImpact;
42
+
43
+ /**
44
+ * Classifies the impact of updating a validation rule's settings.
45
+ * - {@link ValidationRuleUpdateImpact.NONE}: Update does not make existing data invalid (e.g. loosening constraints).
46
+ * - {@link ValidationRuleUpdateImpact.INVALIDATES}: Some existing data may become invalid but can be fixed (e.g. tightening constraints); set owners to draft.
47
+ * - {@link ValidationRuleUpdateImpact.BREAKS}: Only for VALUE_TYPE; all current values become invalid (e.g. changing type); clear values and set owners to draft.
48
+ * @param newSettings - The new settings to classify the update impact for.
49
+ * @returns The impact of the update.
50
+ */
51
+ public abstract classifyUpdate(_: TSettings): ValidationRuleUpdateImpact;
35
52
  }
@@ -1,6 +1,7 @@
1
1
  import { PRN } from "@platecms/delta-plate-resource-notation";
2
2
  import { InvalidValidationRuleSettingsError } from "../errors/invalid-validation-rule-settings.error";
3
3
  import { CountValidationRule } from "./count.validation-rule";
4
+ import { ValidationRuleUpdateImpact } from "../enums/validation-rule-update-impact.enum";
4
5
 
5
6
  describe("CountValidationRule", () => {
6
7
  const rulePrn = PRN.fromString("prn:plate:-1:cms:validation-rule:1");
@@ -64,4 +65,41 @@ describe("CountValidationRule", () => {
64
65
  });
65
66
  });
66
67
  });
68
+
69
+ describe("classifyUpdate", () => {
70
+ it.each([
71
+ // OLD: { min: 1, max: 5 }
72
+ [ValidationRuleUpdateImpact.NONE, { min: 1, max: 5 }, { min: 1, max: 6 }], // widen upper bound
73
+ [ValidationRuleUpdateImpact.NONE, { min: 1, max: 5 }, { min: 0, max: 5 }], // widen lower bound
74
+ [ValidationRuleUpdateImpact.NONE, { min: 1, max: 5 }, { min: 0, max: 6 }], // widen both bounds
75
+ [ValidationRuleUpdateImpact.NONE, { min: 1, max: 5 }, { min: 1 }], // remove upper bound (→ Infinity)
76
+ [ValidationRuleUpdateImpact.NONE, { min: 1, max: 5 }, { max: 5 }], // remove lower bound (→ 0)
77
+ [ValidationRuleUpdateImpact.INVALIDATES, { min: 1, max: 5 }, { min: 0, max: 4 }], // narrow upper bound
78
+ [ValidationRuleUpdateImpact.INVALIDATES, { min: 1, max: 5 }, { min: 2, max: 5 }], // narrow lower bound
79
+ [ValidationRuleUpdateImpact.INVALIDATES, { min: 1, max: 5 }, { min: 2, max: 4 }], // narrow both bounds
80
+ [ValidationRuleUpdateImpact.NONE, { min: 1, max: 5 }, { min: 1, max: 5 }], // unchanged
81
+
82
+ // OLD: { min: 1 } (max = Infinity)
83
+ [ValidationRuleUpdateImpact.NONE, { min: 1 }, { min: 0 }], // widen lower bound
84
+ [ValidationRuleUpdateImpact.INVALIDATES, { min: 1 }, { min: 2 }], // narrow lower bound
85
+ [ValidationRuleUpdateImpact.INVALIDATES, { min: 1 }, { min: 1, max: 5 }], // introduce upper bound (tightening)
86
+ [ValidationRuleUpdateImpact.NONE, { min: 1 }, {}], // remove lower bound (→ 0)
87
+
88
+ // OLD: { max: 5 } (min = 0)
89
+ [ValidationRuleUpdateImpact.NONE, { max: 5 }, { max: 6 }], // widen upper bound
90
+ [ValidationRuleUpdateImpact.INVALIDATES, { max: 5 }, { max: 4 }], // narrow upper bound
91
+ [ValidationRuleUpdateImpact.INVALIDATES, { max: 5 }, { min: 1, max: 5 }], // introduce lower bound (tightening)
92
+ [ValidationRuleUpdateImpact.NONE, { max: 5 }, {}], // remove upper bound (→ Infinity)
93
+ ])("returns %s when old settings are %s and new settings are %s", (expected, oldSettings, newSettings) => {
94
+ const rule = new CountValidationRule(rulePrn, oldSettings);
95
+ expect(rule.classifyUpdate(newSettings)).toBe(expected);
96
+ });
97
+ });
98
+
99
+ describe("classifyCreate", () => {
100
+ it("returns INVALIDATES", () => {
101
+ const rule = new CountValidationRule(rulePrn, { min: 1, max: 3 });
102
+ expect(rule.classifyCreate()).toBe(ValidationRuleUpdateImpact.INVALIDATES);
103
+ });
104
+ });
67
105
  });
@@ -5,6 +5,7 @@ import { ValidationErrorDetails } from "../errors/validation-error-details";
5
5
  import { RuleInstance } from "../types/rule-instance.interface";
6
6
  import { RuleType } from "../enums/rule-types.enum";
7
7
  import { BaseValidationRule, ContentValidationResultType } from "./base.validation-rule";
8
+ import { ValidationRuleUpdateImpact } from "../enums/validation-rule-update-impact.enum";
8
9
 
9
10
  export function validateValuesCount<T>(
10
11
  values: T[],
@@ -25,6 +26,27 @@ export function validateValuesCount<T>(
25
26
  return true;
26
27
  }
27
28
 
29
+ export function validateKeysCount<T>(
30
+ value: T,
31
+ ruleInstance: RuleInstance<CountValidationRuleSettings>,
32
+ ): ContentValidationResultType {
33
+ const indexableValue = value as unknown as Record<string, unknown>;
34
+
35
+ if (ruleInstance.settings.min != null && Object.keys(indexableValue).length < ruleInstance.settings.min) {
36
+ return new ValidationErrorDetails(
37
+ `The amount of provided object keys must be greater than or equal ${ruleInstance.settings.min}.`,
38
+ ruleInstance,
39
+ );
40
+ }
41
+ if (ruleInstance.settings.max != null && Object.keys(indexableValue).length > ruleInstance.settings.max) {
42
+ return new ValidationErrorDetails(
43
+ `The amount of provided object keys must be be less than or equal ${ruleInstance.settings.max}.`,
44
+ ruleInstance,
45
+ );
46
+ }
47
+ return true;
48
+ }
49
+
28
50
  /**
29
51
  * Wrapper for validateValuesCount that wraps the result in an array to keep the same interface as the other validation functions.
30
52
  */
@@ -52,6 +74,19 @@ export class CountValidationRule extends BaseValidationRule<CountValidationRuleS
52
74
  public override validate(value: ContentValueType[]): ContentValidationResultType[] {
53
75
  return validateCount(value, this);
54
76
  }
77
+
78
+ public override classifyCreate(): ValidationRuleUpdateImpact {
79
+ return ValidationRuleUpdateImpact.INVALIDATES;
80
+ }
81
+
82
+ public override classifyUpdate(newSettings: CountValidationRuleSettings): ValidationRuleUpdateImpact {
83
+ const oldSettings = this.settings;
84
+ const [oldMin, oldMax] = [oldSettings.min ?? 0, oldSettings.max ?? Infinity];
85
+ const [newMin, newMax] = [newSettings.min ?? 0, newSettings.max ?? Infinity];
86
+ const isLoosening = newMin <= oldMin && newMax >= oldMax;
87
+
88
+ return isLoosening ? ValidationRuleUpdateImpact.NONE : ValidationRuleUpdateImpact.INVALIDATES;
89
+ }
55
90
  }
56
91
 
57
92
  export interface CountValidationRuleSettings {
@@ -1,4 +1,5 @@
1
1
  import { PRN } from "@platecms/delta-plate-resource-notation";
2
+ import { ValidationRuleUpdateImpact } from "../enums/validation-rule-update-impact.enum";
2
3
  import { InvalidValidationRuleSettingsError } from "../errors/invalid-validation-rule-settings.error";
3
4
  import { DateBetweenValidationRule } from "./date-between.validation-rule";
4
5
 
@@ -74,4 +75,86 @@ describe("DateBetweenValidationRule", () => {
74
75
  });
75
76
  });
76
77
  });
78
+
79
+ describe("classifyUpdate", () => {
80
+ // eslint-disable-next-line func-style, id-length
81
+ const d = (iso: string): Date => new Date(iso);
82
+
83
+ it.each([
84
+ // OLD: bounded range
85
+ [
86
+ ValidationRuleUpdateImpact.NONE,
87
+ { start: d("2025-01-10"), end: d("2025-01-20") },
88
+ { start: d("2025-01-10"), end: d("2025-01-21") },
89
+ ], // widen end
90
+ [
91
+ ValidationRuleUpdateImpact.NONE,
92
+ { start: d("2025-01-10"), end: d("2025-01-20") },
93
+ { start: d("2025-01-09"), end: d("2025-01-20") },
94
+ ], // widen start (earlier)
95
+ [
96
+ ValidationRuleUpdateImpact.NONE,
97
+ { start: d("2025-01-10"), end: d("2025-01-20") },
98
+ { start: d("2025-01-09"), end: d("2025-01-21") },
99
+ ], // widen both
100
+
101
+ [ValidationRuleUpdateImpact.NONE, { start: d("2025-01-10"), end: d("2025-01-20") }, { start: d("2025-01-10") }], // remove end (→ Infinity)
102
+ [ValidationRuleUpdateImpact.NONE, { start: d("2025-01-10"), end: d("2025-01-20") }, { end: d("2025-01-20") }], // remove start (→ -Infinity)
103
+ [ValidationRuleUpdateImpact.NONE, { start: d("2025-01-10"), end: d("2025-01-20") }, {}], // remove both
104
+ [
105
+ ValidationRuleUpdateImpact.INVALIDATES,
106
+ { start: d("2025-01-10"), end: d("2025-01-20") },
107
+ { start: d("2025-01-10"), end: d("2025-01-19") },
108
+ ], // narrow end (earlier)
109
+ [
110
+ ValidationRuleUpdateImpact.INVALIDATES,
111
+ { start: d("2025-01-10"), end: d("2025-01-20") },
112
+ { start: d("2025-01-11"), end: d("2025-01-20") },
113
+ ], // narrow start (later)
114
+ [
115
+ ValidationRuleUpdateImpact.INVALIDATES,
116
+ { start: d("2025-01-10"), end: d("2025-01-20") },
117
+ { start: d("2025-01-11"), end: d("2025-01-19") },
118
+ ], // narrow both
119
+ // unchanged
120
+ [
121
+ ValidationRuleUpdateImpact.NONE,
122
+ { start: d("2025-01-10"), end: d("2025-01-20") },
123
+ { start: d("2025-01-10"), end: d("2025-01-20") },
124
+ ], // unchanged
125
+
126
+ // OLD: start-only (end = Infinity)
127
+ [ValidationRuleUpdateImpact.NONE, { start: d("2025-01-10") }, { start: d("2025-01-09") }], // widen start (earlier)
128
+ [ValidationRuleUpdateImpact.INVALIDATES, { start: d("2025-01-10") }, { start: d("2025-01-11") }], // narrow start (later)
129
+ [
130
+ ValidationRuleUpdateImpact.INVALIDATES,
131
+ { start: d("2025-01-10") },
132
+ { start: d("2025-01-10"), end: d("2025-01-20") },
133
+ ], // introduce end (tightening)
134
+ [ValidationRuleUpdateImpact.NONE, { start: d("2025-01-10") }, {}], // remove start (→ -Infinity)
135
+
136
+ // OLD: end-only (start = -Infinity)
137
+ [ValidationRuleUpdateImpact.NONE, { end: d("2025-01-20") }, { end: d("2025-01-21") }], // widen end (later)
138
+ [ValidationRuleUpdateImpact.INVALIDATES, { end: d("2025-01-20") }, { end: d("2025-01-19") }], // narrow end (earlier)
139
+ [
140
+ ValidationRuleUpdateImpact.INVALIDATES,
141
+ { end: d("2025-01-20") },
142
+ { start: d("2025-01-10"), end: d("2025-01-20") },
143
+ ], // introduce start (tightening)
144
+ [ValidationRuleUpdateImpact.NONE, { end: d("2025-01-20") }, {}], // remove end (→ Infinity)
145
+ ])("returns %s when old settings are %s and new settings are %s", (expected, oldSettings, newSettings) => {
146
+ const rule = new DateBetweenValidationRule(rulePrn, oldSettings);
147
+ expect(rule.classifyUpdate(newSettings)).toBe(expected);
148
+ });
149
+ });
150
+
151
+ describe("classifyCreate", () => {
152
+ it("returns INVALIDATES", () => {
153
+ const rule = new DateBetweenValidationRule(rulePrn, {
154
+ start: new Date(2020, 1, 2, 3, 4, 5),
155
+ end: new Date(2020, 1, 3, 3, 4, 6),
156
+ });
157
+ expect(rule.classifyCreate()).toBe(ValidationRuleUpdateImpact.INVALIDATES);
158
+ });
159
+ });
77
160
  });
@@ -6,6 +6,7 @@ import { RuleInstance } from "../types/rule-instance.interface";
6
6
  import { RuleType } from "../enums/rule-types.enum";
7
7
  import { BaseValidationRule, ContentValidationResultType } from "./base.validation-rule";
8
8
  import { validationFunctionFromValidateValue } from "./validation-rule-function.factory";
9
+ import { ValidationRuleUpdateImpact } from "../enums/validation-rule-update-impact.enum";
9
10
 
10
11
  export function validateValueDateBetween(
11
12
  value: ContentValueType,
@@ -46,6 +47,20 @@ export class DateBetweenValidationRule extends BaseValidationRule<DateBetweenVal
46
47
  public override validate(values: ContentValueType[]): ContentValidationResultType[] {
47
48
  return validateDateBetween(values, this);
48
49
  }
50
+
51
+ public override classifyCreate(): ValidationRuleUpdateImpact {
52
+ return ValidationRuleUpdateImpact.INVALIDATES;
53
+ }
54
+
55
+ public override classifyUpdate(newSettings: DateBetweenValidationRuleSettings): ValidationRuleUpdateImpact {
56
+ const oldStart = this.settings.start ? this.settings.start.getTime() : -Infinity;
57
+ const oldEnd = this.settings.end ? this.settings.end.getTime() : Infinity;
58
+ const newStart = newSettings.start ? newSettings.start.getTime() : -Infinity;
59
+ const newEnd = newSettings.end ? newSettings.end.getTime() : Infinity;
60
+ const extending = newStart <= oldStart && newEnd >= oldEnd;
61
+
62
+ return extending ? ValidationRuleUpdateImpact.NONE : ValidationRuleUpdateImpact.INVALIDATES;
63
+ }
49
64
  }
50
65
 
51
66
  export interface DateBetweenValidationRuleSettings {