@active-record-ts/active-model 1.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.
package/src/Type.ts ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Type system for ActiveModel attributes.
3
+ *
4
+ * Each Type owns three coercion functions:
5
+ * - `cast` converts a value coming from user code (`record.name = 1`)
6
+ * into the canonical attribute value (e.g. `"1"` for a string column).
7
+ * - `serialize` converts an attribute value into a driver-compatible
8
+ * value for INSERT/UPDATE statements.
9
+ * - `deserialize` converts a value coming back from the driver into an
10
+ * attribute value.
11
+ *
12
+ * The named type registry mirrors Rails' `ActiveModel::Type` registry.
13
+ * Adapters can register adapter-specific types (e.g. PostgreSQL's `uuid`
14
+ * or `jsonb`) by calling `Type.register(name, factory)`.
15
+ */
16
+
17
+ /** Base interface every type implementation satisfies. */
18
+ export interface Type<T = unknown> {
19
+ /** Name of the type as registered (e.g. `'string'`, `'integer'`). */
20
+ readonly type: string;
21
+ cast(value: unknown): T | null;
22
+ serialize(value: T | null): unknown;
23
+ deserialize(value: unknown): T | null;
24
+ /** Returns true when the two values represent the same logical attribute value. */
25
+ equals?(a: T | null, b: T | null): boolean;
26
+ }
27
+
28
+ /** Helper to bail out of casting NULL-ish values uniformly. */
29
+ const isNullish = (value: unknown): boolean => value === null || value === undefined;
30
+
31
+ export class StringType implements Type<string> {
32
+ readonly type = 'string';
33
+ cast(value: unknown): string | null {
34
+ if (isNullish(value)) return null;
35
+ if (typeof value === 'string') return value;
36
+ if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value);
37
+ if (value instanceof Date) return value.toISOString();
38
+ return String(value);
39
+ }
40
+ serialize(value: string | null) {
41
+ return value;
42
+ }
43
+ deserialize(value: unknown): string | null {
44
+ return this.cast(value);
45
+ }
46
+ }
47
+
48
+ export class IntegerType implements Type<number> {
49
+ readonly type = 'integer';
50
+ cast(value: unknown): number | null {
51
+ if (isNullish(value) || value === '') return null;
52
+ if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : null;
53
+ if (typeof value === 'bigint') return Number(value);
54
+ if (typeof value === 'boolean') return value ? 1 : 0;
55
+ if (typeof value === 'string') {
56
+ const parsed = Number.parseInt(value, 10);
57
+ return Number.isNaN(parsed) ? null : parsed;
58
+ }
59
+ return null;
60
+ }
61
+ serialize(value: number | null) {
62
+ return value;
63
+ }
64
+ deserialize(value: unknown): number | null {
65
+ return this.cast(value);
66
+ }
67
+ }
68
+
69
+ export class BigIntType implements Type<bigint> {
70
+ readonly type = 'bigint';
71
+ cast(value: unknown): bigint | null {
72
+ if (isNullish(value) || value === '') return null;
73
+ if (typeof value === 'bigint') return value;
74
+ if (typeof value === 'number') return Number.isFinite(value) ? BigInt(Math.trunc(value)) : null;
75
+ if (typeof value === 'string') {
76
+ try {
77
+ return BigInt(value);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+ serialize(value: bigint | null) {
85
+ return value;
86
+ }
87
+ deserialize(value: unknown): bigint | null {
88
+ return this.cast(value);
89
+ }
90
+ }
91
+
92
+ export class FloatType implements Type<number> {
93
+ readonly type = 'float';
94
+ cast(value: unknown): number | null {
95
+ if (isNullish(value) || value === '') return null;
96
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
97
+ if (typeof value === 'bigint') return Number(value);
98
+ if (typeof value === 'string') {
99
+ const parsed = Number.parseFloat(value);
100
+ return Number.isNaN(parsed) ? null : parsed;
101
+ }
102
+ if (typeof value === 'boolean') return value ? 1 : 0;
103
+ return null;
104
+ }
105
+ serialize(value: number | null) {
106
+ return value;
107
+ }
108
+ deserialize(value: unknown): number | null {
109
+ return this.cast(value);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Stored as a string to preserve precision. Drivers commonly return decimals
115
+ * as strings; we keep them that way for callers to feed into a decimal lib
116
+ * of choice (we don't ship one).
117
+ */
118
+ export class DecimalType implements Type<string> {
119
+ readonly type = 'decimal';
120
+ cast(value: unknown): string | null {
121
+ if (isNullish(value) || value === '') return null;
122
+ if (typeof value === 'string') return value;
123
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value);
124
+ return String(value);
125
+ }
126
+ serialize(value: string | null) {
127
+ return value;
128
+ }
129
+ deserialize(value: unknown): string | null {
130
+ return this.cast(value);
131
+ }
132
+ }
133
+
134
+ const TRUTHY = new Set(['1', 't', 'T', 'true', 'TRUE', 'on', 'ON', 1, true]);
135
+ const FALSY = new Set(['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF', 0, false]);
136
+
137
+ export class BooleanType implements Type<boolean> {
138
+ readonly type = 'boolean';
139
+ cast(value: unknown): boolean | null {
140
+ if (isNullish(value) || value === '') return null;
141
+ if (TRUTHY.has(value as never)) return true;
142
+ if (FALSY.has(value as never)) return false;
143
+ return Boolean(value);
144
+ }
145
+ serialize(value: boolean | null) {
146
+ return value;
147
+ }
148
+ deserialize(value: unknown): boolean | null {
149
+ return this.cast(value);
150
+ }
151
+ }
152
+
153
+ const dateOnly = (date: Date): Date => {
154
+ const d = new Date(date.getTime());
155
+ d.setUTCHours(0, 0, 0, 0);
156
+ return d;
157
+ };
158
+
159
+ export class DateType implements Type<Date> {
160
+ readonly type = 'date';
161
+ cast(value: unknown): Date | null {
162
+ if (isNullish(value) || value === '') return null;
163
+ if (value instanceof Date) return dateOnly(value);
164
+ if (typeof value === 'string') {
165
+ const d = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00.000Z` : value);
166
+ return Number.isNaN(d.getTime()) ? null : dateOnly(d);
167
+ }
168
+ if (typeof value === 'number') {
169
+ const d = new Date(value);
170
+ return Number.isNaN(d.getTime()) ? null : dateOnly(d);
171
+ }
172
+ return null;
173
+ }
174
+ serialize(value: Date | null): string | null {
175
+ if (value == null) return null;
176
+ return value.toISOString().slice(0, 10);
177
+ }
178
+ deserialize(value: unknown): Date | null {
179
+ return this.cast(value);
180
+ }
181
+ equals(a: Date | null, b: Date | null): boolean {
182
+ if (a == null || b == null) return a === b;
183
+ return a.getTime() === b.getTime();
184
+ }
185
+ }
186
+
187
+ export class DateTimeType implements Type<Date> {
188
+ readonly type = 'datetime';
189
+ cast(value: unknown): Date | null {
190
+ if (isNullish(value) || value === '') return null;
191
+ if (value instanceof Date) return new Date(value.getTime());
192
+ if (typeof value === 'string') {
193
+ const d = new Date(value);
194
+ return Number.isNaN(d.getTime()) ? null : d;
195
+ }
196
+ if (typeof value === 'number') {
197
+ const d = new Date(value);
198
+ return Number.isNaN(d.getTime()) ? null : d;
199
+ }
200
+ return null;
201
+ }
202
+ serialize(value: Date | null): string | null {
203
+ if (value == null) return null;
204
+ return value.toISOString();
205
+ }
206
+ deserialize(value: unknown): Date | null {
207
+ return this.cast(value);
208
+ }
209
+ equals(a: Date | null, b: Date | null): boolean {
210
+ if (a == null || b == null) return a === b;
211
+ return a.getTime() === b.getTime();
212
+ }
213
+ }
214
+
215
+ export class JSONType<T = unknown> implements Type<T> {
216
+ readonly type = 'json';
217
+ cast(value: unknown): T | null {
218
+ if (isNullish(value)) return null;
219
+ if (typeof value === 'string') {
220
+ try {
221
+ return JSON.parse(value) as T;
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+ return value as T;
227
+ }
228
+ serialize(value: T | null): string | null {
229
+ if (value == null) return null;
230
+ return JSON.stringify(value);
231
+ }
232
+ deserialize(value: unknown): T | null {
233
+ return this.cast(value);
234
+ }
235
+ }
236
+
237
+ export class BinaryType implements Type<Uint8Array> {
238
+ readonly type = 'binary';
239
+ cast(value: unknown): Uint8Array | null {
240
+ if (isNullish(value)) return null;
241
+ if (value instanceof Uint8Array) return value;
242
+ if (typeof value === 'string') return new TextEncoder().encode(value);
243
+ if (Array.isArray(value)) return new Uint8Array(value as number[]);
244
+ return null;
245
+ }
246
+ serialize(value: Uint8Array | null) {
247
+ return value;
248
+ }
249
+ deserialize(value: unknown): Uint8Array | null {
250
+ return this.cast(value);
251
+ }
252
+ equals(a: Uint8Array | null, b: Uint8Array | null): boolean {
253
+ if (a == null || b == null) return a === b;
254
+ if (a.length !== b.length) return false;
255
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
256
+ return true;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Pass-through type for unmapped or adapter-specific values. Used as the
262
+ * default when schema reflection finds a column type we don't know about.
263
+ */
264
+ export class ValueType implements Type<unknown> {
265
+ readonly type = 'value';
266
+ cast(value: unknown) {
267
+ return isNullish(value) ? null : value;
268
+ }
269
+ serialize(value: unknown) {
270
+ return value;
271
+ }
272
+ deserialize(value: unknown) {
273
+ return isNullish(value) ? null : value;
274
+ }
275
+ }
276
+
277
+ /** Compare two values via the type's `equals` hook if present, else `===`. */
278
+ export const valuesEqual = <T>(type: Type<T>, a: T | null, b: T | null): boolean =>
279
+ type.equals ? type.equals(a, b) : a === b;
280
+
281
+ type Factory = () => Type;
282
+
283
+ const REGISTRY = new Map<string, Factory>();
284
+
285
+ /** Register a named type (factory invoked per `Type.lookup`). */
286
+ export const registerType = (name: string, factory: Factory): void => {
287
+ REGISTRY.set(name, factory);
288
+ };
289
+
290
+ /** Resolve a named type. Throws if unregistered. */
291
+ export const lookupType = (name: string): Type => {
292
+ const factory = REGISTRY.get(name);
293
+ if (!factory) throw new Error(`Unknown attribute type: ${name}`);
294
+ return factory();
295
+ };
296
+
297
+ /** True when the named type has been registered. */
298
+ export const hasType = (name: string): boolean => REGISTRY.has(name);
299
+
300
+ registerType('string', () => new StringType());
301
+ registerType('text', () => new StringType());
302
+ registerType('integer', () => new IntegerType());
303
+ registerType('int', () => new IntegerType());
304
+ registerType('bigint', () => new BigIntType());
305
+ registerType('float', () => new FloatType());
306
+ registerType('double', () => new FloatType());
307
+ registerType('decimal', () => new DecimalType());
308
+ registerType('numeric', () => new DecimalType());
309
+ registerType('boolean', () => new BooleanType());
310
+ registerType('bool', () => new BooleanType());
311
+ registerType('date', () => new DateType());
312
+ registerType('datetime', () => new DateTimeType());
313
+ registerType('timestamp', () => new DateTimeType());
314
+ registerType('time', () => new DateTimeType());
315
+ registerType('json', () => new JSONType());
316
+ registerType('jsonb', () => new JSONType());
317
+ registerType('binary', () => new BinaryType());
318
+ registerType('blob', () => new BinaryType());
319
+ registerType('bytea', () => new BinaryType());
320
+ registerType('value', () => new ValueType());
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Built-in validators. Each validator implements `validate(record, errors)`.
3
+ * Adapter-level validators (e.g. uniqueness) live in active-record.
4
+ */
5
+
6
+ import type { Errors } from './Errors';
7
+
8
+ /**
9
+ * Optional context name passed to `Model#validate(context)`. Mirrors Rails'
10
+ * `valid?(:create)`/`valid?(:update)` — validators with `on` matching the
11
+ * context (or with no `on` at all) are evaluated; others are skipped.
12
+ */
13
+ export type ValidationContext = string;
14
+
15
+ export interface Validator<T = unknown> {
16
+ validate(record: T, errors: Errors, context?: ValidationContext): void | Promise<void>;
17
+ }
18
+
19
+ type Reader<T> = (record: T) => unknown;
20
+
21
+ const blank = (value: unknown): boolean => {
22
+ if (value === null || value === undefined) return true;
23
+ if (typeof value === 'string') return value.trim() === '';
24
+ if (Array.isArray(value)) return value.length === 0;
25
+ if (value instanceof Set || value instanceof Map) return value.size === 0;
26
+ return false;
27
+ };
28
+
29
+ /** Common options shared across all validators. */
30
+ export type ValidatorOptions<T> = {
31
+ /**
32
+ * Custom error message. Can be a string or a function that receives
33
+ * `(record, data)` like Rails' Proc messages, where `data` is
34
+ * `{ attribute, value, type }`.
35
+ */
36
+ message?: string | ((record: T, data: { attribute: string; value: unknown; type?: string }) => string);
37
+ if?: (record: T) => boolean;
38
+ unless?: (record: T) => boolean;
39
+ allowNull?: boolean;
40
+ allowBlank?: boolean;
41
+ /** Limit the validator to one or more validation contexts (e.g. `'create'`). */
42
+ on?: ValidationContext | ValidationContext[];
43
+ /** Skip the validator when the validation context matches one of these. */
44
+ exceptOn?: ValidationContext | ValidationContext[];
45
+ /**
46
+ * When set, validator failures throw immediately instead of accumulating
47
+ * in `record.errors`. Pass `true` for a generic `StrictValidationFailed`
48
+ * or a custom Error subclass to use that instead. Mirrors Rails'
49
+ * `validates(..., strict: true)`.
50
+ */
51
+ strict?: boolean | (new (message: string) => Error);
52
+ };
53
+
54
+ /** Thrown when a validator marked `strict: true` fails. */
55
+ export class StrictValidationFailed extends Error {
56
+ constructor(message: string) {
57
+ super(message);
58
+ }
59
+ }
60
+
61
+ const shouldValidate = <T>(record: T, options: ValidatorOptions<T> = {}, context?: ValidationContext): boolean => {
62
+ if (options.on !== undefined) {
63
+ const wanted = Array.isArray(options.on) ? options.on : [options.on];
64
+ if (context === undefined) return false;
65
+ if (!wanted.includes(context)) return false;
66
+ }
67
+ if (options.exceptOn !== undefined && context !== undefined) {
68
+ const excluded = Array.isArray(options.exceptOn) ? options.exceptOn : [options.exceptOn];
69
+ if (excluded.includes(context)) return false;
70
+ }
71
+ if (options.if && !options.if(record)) return false;
72
+ if (options.unless && options.unless(record)) return false;
73
+ return true;
74
+ };
75
+
76
+ const skipForNullable = (value: unknown, options: { allowNull?: boolean; allowBlank?: boolean } = {}): boolean => {
77
+ if (options.allowNull && (value === null || value === undefined)) return true;
78
+ if (options.allowBlank && blank(value)) return true;
79
+ return false;
80
+ };
81
+
82
+ /** Read a value from the record by attribute name, type-safe enough. */
83
+ const reader = <T>(attribute: string): Reader<T> => (r: T) => (r as Record<string, unknown>)[attribute];
84
+
85
+ /**
86
+ * Record an error, honoring `strict` if set. With `strict: true`, throws a
87
+ * `StrictValidationFailed` whose message is the humanized "Attribute message".
88
+ * With `strict: SomeError`, throws an instance of that error class instead.
89
+ */
90
+ const recordError = <T>(
91
+ options: ValidatorOptions<T>,
92
+ errors: Errors,
93
+ attribute: string,
94
+ defaultMessage: string,
95
+ meta: { type?: string; options?: Record<string, unknown> } = {},
96
+ record?: T,
97
+ value?: unknown,
98
+ ): void => {
99
+ // Resolve a Proc-style message if the user supplied one; otherwise use
100
+ // their string override; otherwise fall back to the default for this
101
+ // validator.
102
+ let message: string;
103
+ if (typeof options.message === 'function') {
104
+ if (record === undefined) {
105
+ // No record context — fall back to default.
106
+ message = defaultMessage;
107
+ } else {
108
+ message = options.message(record, { attribute, value, type: meta.type });
109
+ }
110
+ } else if (typeof options.message === 'string') {
111
+ message = options.message;
112
+ } else {
113
+ message = defaultMessage;
114
+ }
115
+ if (options.strict) {
116
+ const fullMessage = errors.fullMessage(attribute, message);
117
+ if (typeof options.strict === 'function') {
118
+ throw new options.strict(fullMessage);
119
+ }
120
+ throw new StrictValidationFailed(fullMessage);
121
+ }
122
+ errors.add(attribute, message, meta as never);
123
+ };
124
+
125
+ /**
126
+ * Free-form block validator — wraps a `(record, errors) => void` callback.
127
+ * Exposes `attributes: []` and `kind: 'block'` so introspection (`validators`,
128
+ * `validatorsOn`) can still surface it.
129
+ */
130
+ export class BlockValidator<T> implements Validator<T> {
131
+ readonly kind = 'block';
132
+ readonly attributes: string[] = [];
133
+ constructor(private readonly fn: (record: T, errors: Errors, context?: ValidationContext) => void | Promise<void>, private readonly options: ValidatorOptions<T> = {}) {}
134
+ async validate(record: T, errors: Errors, context?: ValidationContext): Promise<void> {
135
+ if (!shouldValidate(record, this.options, context)) return;
136
+ await this.fn(record, errors, context);
137
+ }
138
+ }
139
+
140
+ export class PresenceValidator<T> implements Validator<T> {
141
+ readonly kind = 'presence';
142
+ readonly attributes: string[];
143
+ constructor(private readonly attribute: string, private readonly options: ValidatorOptions<T> = {}) {
144
+ this.attributes = [attribute];
145
+ }
146
+ validate(record: T, errors: Errors, context?: ValidationContext): void {
147
+ if (!shouldValidate(record, this.options, context)) return;
148
+ const value = reader<T>(this.attribute)(record);
149
+ if (blank(value)) {
150
+ recordError(this.options, errors, this.attribute, "can't be blank", { type: 'presence' }, record, value);
151
+ }
152
+ }
153
+ }
154
+
155
+ export class AbsenceValidator<T> implements Validator<T> {
156
+ readonly kind = 'absence';
157
+ readonly attributes: string[];
158
+ constructor(private readonly attribute: string, private readonly options: ValidatorOptions<T> = {}) {
159
+ this.attributes = [attribute];
160
+ }
161
+ validate(record: T, errors: Errors, context?: ValidationContext): void {
162
+ if (!shouldValidate(record, this.options, context)) return;
163
+ const value = reader<T>(this.attribute)(record);
164
+ if (!blank(value)) {
165
+ recordError(this.options, errors, this.attribute, 'must be blank', { type: 'absence' }, record, value);
166
+ }
167
+ }
168
+ }
169
+
170
+ export type LengthOptions<T> = ValidatorOptions<T> & {
171
+ minimum?: number;
172
+ maximum?: number;
173
+ is?: number;
174
+ in?: [number, number];
175
+ tooShort?: string;
176
+ tooLong?: string;
177
+ wrongLength?: string;
178
+ };
179
+
180
+ export class LengthValidator<T> implements Validator<T> {
181
+ readonly kind = 'length';
182
+ readonly attributes: string[];
183
+ constructor(private readonly attribute: string, private readonly options: LengthOptions<T> = {}) {
184
+ this.attributes = [attribute];
185
+ }
186
+ validate(record: T, errors: Errors, context?: ValidationContext): void {
187
+ if (!shouldValidate(record, this.options, context)) return;
188
+ const value = reader<T>(this.attribute)(record);
189
+ if (skipForNullable(value, this.options)) return;
190
+ const len = lengthOf(value);
191
+ const o = this.options;
192
+ if (o.in) {
193
+ const [min, max] = o.in;
194
+ if (len < min) {
195
+ recordError(this.options, errors, this.attribute, o.tooShort ?? `is too short (minimum is ${min} characters)`, { type: 'length' }, record, value);
196
+ return;
197
+ }
198
+ if (len > max) {
199
+ recordError(this.options, errors, this.attribute, o.tooLong ?? `is too long (maximum is ${max} characters)`, { type: 'length' }, record, value);
200
+ return;
201
+ }
202
+ }
203
+ if (o.is !== undefined && len !== o.is) {
204
+ recordError(this.options, errors, this.attribute, o.wrongLength ?? `is the wrong length (should be ${o.is} characters)`, { type: 'length' }, record, value);
205
+ return;
206
+ }
207
+ if (o.minimum !== undefined && len < o.minimum) {
208
+ recordError(this.options, errors, this.attribute, o.tooShort ?? `is too short (minimum is ${o.minimum} characters)`, { type: 'length' }, record, value);
209
+ return;
210
+ }
211
+ if (o.maximum !== undefined && len > o.maximum) {
212
+ recordError(this.options, errors, this.attribute, o.tooLong ?? `is too long (maximum is ${o.maximum} characters)`, { type: 'length' }, record, value);
213
+ return;
214
+ }
215
+ }
216
+ }
217
+
218
+ const lengthOf = (value: unknown): number => {
219
+ if (value == null) return 0;
220
+ if (typeof value === 'string' || Array.isArray(value)) return value.length;
221
+ if (value instanceof Set || value instanceof Map) return value.size;
222
+ return String(value).length;
223
+ };
224
+
225
+ export type FormatOptions<T> = ValidatorOptions<T> & { with?: RegExp; without?: RegExp };
226
+
227
+ export class FormatValidator<T> implements Validator<T> {
228
+ readonly kind = 'format';
229
+ readonly attributes: string[];
230
+ constructor(private readonly attribute: string, private readonly options: FormatOptions<T>) {
231
+ this.attributes = [attribute];
232
+ }
233
+ validate(record: T, errors: Errors, context?: ValidationContext): void {
234
+ if (!shouldValidate(record, this.options, context)) return;
235
+ const value = reader<T>(this.attribute)(record);
236
+ if (skipForNullable(value, this.options)) return;
237
+ const str = value == null ? '' : String(value);
238
+ if (this.options.with && !this.options.with.test(str)) {
239
+ recordError(this.options, errors, this.attribute, 'is invalid', { type: 'format' }, record, value);
240
+ }
241
+ if (this.options.without && this.options.without.test(str)) {
242
+ recordError(this.options, errors, this.attribute, 'is invalid', { type: 'format' }, record, value);
243
+ }
244
+ }
245
+ }
246
+
247
+ export type InclusionOptions<T> = ValidatorOptions<T> & { in: readonly unknown[] };
248
+
249
+ export class InclusionValidator<T> implements Validator<T> {
250
+ readonly kind = 'inclusion';
251
+ readonly attributes: string[];
252
+ constructor(private readonly attribute: string, private readonly options: InclusionOptions<T>) {
253
+ this.attributes = [attribute];
254
+ }
255
+ validate(record: T, errors: Errors, context?: ValidationContext): void {
256
+ if (!shouldValidate(record, this.options, context)) return;
257
+ const value = reader<T>(this.attribute)(record);
258
+ if (skipForNullable(value, this.options)) return;
259
+ if (!this.options.in.includes(value)) {
260
+ recordError(this.options, errors, this.attribute, 'is not included in the list', { type: 'inclusion' }, record, value);
261
+ }
262
+ }
263
+ }
264
+
265
+ export type ExclusionOptions<T> = ValidatorOptions<T> & { in: readonly unknown[] };
266
+
267
+ export class ExclusionValidator<T> implements Validator<T> {
268
+ readonly kind = 'exclusion';
269
+ readonly attributes: string[];
270
+ constructor(private readonly attribute: string, private readonly options: ExclusionOptions<T>) {
271
+ this.attributes = [attribute];
272
+ }
273
+ validate(record: T, errors: Errors, context?: ValidationContext): void {
274
+ if (!shouldValidate(record, this.options, context)) return;
275
+ const value = reader<T>(this.attribute)(record);
276
+ if (skipForNullable(value, this.options)) return;
277
+ if (this.options.in.includes(value)) {
278
+ recordError(this.options, errors, this.attribute, 'is reserved', { type: 'exclusion' }, record, value);
279
+ }
280
+ }
281
+ }
282
+
283
+ export type NumericalityOptions<T> = ValidatorOptions<T> & {
284
+ onlyInteger?: boolean;
285
+ greaterThan?: number;
286
+ greaterThanOrEqualTo?: number;
287
+ lessThan?: number;
288
+ lessThanOrEqualTo?: number;
289
+ equalTo?: number;
290
+ odd?: boolean;
291
+ even?: boolean;
292
+ };
293
+
294
+ export class NumericalityValidator<T> implements Validator<T> {
295
+ readonly kind = 'numericality';
296
+ readonly attributes: string[];
297
+ constructor(private readonly attribute: string, private readonly options: NumericalityOptions<T> = {}) {
298
+ this.attributes = [attribute];
299
+ }
300
+ validate(record: T, errors: Errors, context?: ValidationContext): void {
301
+ if (!shouldValidate(record, this.options, context)) return;
302
+ const value = reader<T>(this.attribute)(record);
303
+ if (skipForNullable(value, this.options)) return;
304
+ const num = Number(value);
305
+ if (Number.isNaN(num) || !Number.isFinite(num)) {
306
+ recordError(this.options, errors, this.attribute, 'is not a number', { type: 'numericality' }, record, value);
307
+ return;
308
+ }
309
+ const o = this.options;
310
+ if (o.onlyInteger && !Number.isInteger(num)) {
311
+ recordError(this.options, errors, this.attribute, 'must be an integer', { type: 'numericality.only_integer' });
312
+ }
313
+ if (o.greaterThan !== undefined && !(num > o.greaterThan)) {
314
+ recordError(this.options, errors, this.attribute, `must be greater than ${o.greaterThan}`, { type: 'numericality.greater_than' }, record, value);
315
+ }
316
+ if (o.greaterThanOrEqualTo !== undefined && !(num >= o.greaterThanOrEqualTo)) {
317
+ recordError(this.options, errors, this.attribute, `must be greater than or equal to ${o.greaterThanOrEqualTo}`, { type: 'numericality.greater_than_or_equal_to' }, record, value);
318
+ }
319
+ if (o.lessThan !== undefined && !(num < o.lessThan)) {
320
+ recordError(this.options, errors, this.attribute, `must be less than ${o.lessThan}`, { type: 'numericality.less_than' }, record, value);
321
+ }
322
+ if (o.lessThanOrEqualTo !== undefined && !(num <= o.lessThanOrEqualTo)) {
323
+ recordError(this.options, errors, this.attribute, `must be less than or equal to ${o.lessThanOrEqualTo}`, { type: 'numericality.less_than_or_equal_to' }, record, value);
324
+ }
325
+ if (o.equalTo !== undefined && num !== o.equalTo) {
326
+ recordError(this.options, errors, this.attribute, `must be equal to ${o.equalTo}`, { type: 'numericality.equal_to' }, record, value);
327
+ }
328
+ if (o.odd && num % 2 === 0) recordError(this.options, errors, this.attribute, 'must be odd', { type: 'numericality.odd' });
329
+ if (o.even && num % 2 !== 0) recordError(this.options, errors, this.attribute, 'must be even', { type: 'numericality.even' });
330
+ }
331
+ }
332
+
333
+ export type AcceptanceOptions<T> = ValidatorOptions<T> & { accept?: readonly unknown[] };
334
+
335
+ export class AcceptanceValidator<T> implements Validator<T> {
336
+ readonly kind = 'acceptance';
337
+ readonly attributes: string[];
338
+ constructor(private readonly attribute: string, private readonly options: AcceptanceOptions<T> = {}) {
339
+ this.attributes = [attribute];
340
+ }
341
+ validate(record: T, errors: Errors, context?: ValidationContext): void {
342
+ if (!shouldValidate(record, this.options, context)) return;
343
+ const accept = this.options.accept ?? [true, '1', 1];
344
+ const value = reader<T>(this.attribute)(record);
345
+ if (!accept.includes(value)) {
346
+ recordError(this.options, errors, this.attribute, 'must be accepted', { type: 'acceptance' }, record, value);
347
+ }
348
+ }
349
+ }
350
+
351
+ export type ConfirmationOptions<T> = ValidatorOptions<T> & { caseSensitive?: boolean };
352
+
353
+ export class ConfirmationValidator<T> implements Validator<T> {
354
+ readonly kind = 'confirmation';
355
+ readonly attributes: string[];
356
+ constructor(private readonly attribute: string, private readonly options: ConfirmationOptions<T> = {}) {
357
+ this.attributes = [attribute];
358
+ }
359
+ validate(record: T, errors: Errors, context?: ValidationContext): void {
360
+ if (!shouldValidate(record, this.options, context)) return;
361
+ const value = reader<T>(this.attribute)(record);
362
+ const confirmation = reader<T>(`${this.attribute}Confirmation`)(record);
363
+ if (confirmation === undefined) return;
364
+ const a = value == null ? '' : String(value);
365
+ const b = confirmation == null ? '' : String(confirmation);
366
+ const equal = this.options.caseSensitive === false ? a.toLowerCase() === b.toLowerCase() : a === b;
367
+ if (!equal) {
368
+ recordError(this.options, errors, `${this.attribute}Confirmation`, "doesn't match", { type: 'confirmation' }, record, value);
369
+ }
370
+ }
371
+ }