@orieljs/validation 0.1.0-alpha.1

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/dist/index.js ADDED
@@ -0,0 +1,870 @@
1
+ // errors.ts
2
+ var ValidationErrorImpl = class extends Error {
3
+ constructor(errors, errorBag = "default", redirectTo) {
4
+ super("The given data was invalid.");
5
+ this.errors = errors;
6
+ this.errorBag = errorBag;
7
+ this.redirectTo = redirectTo;
8
+ }
9
+ errors;
10
+ errorBag;
11
+ redirectTo;
12
+ kind = "Validation";
13
+ status = 422;
14
+ name = "ValidationError";
15
+ };
16
+ var AuthorizationErrorImpl = class extends Error {
17
+ kind = "Authorization";
18
+ status = 403;
19
+ name = "AuthorizationError";
20
+ constructor(message = "This action is unauthorized.") {
21
+ super(message);
22
+ }
23
+ };
24
+ var UnknownValidationRuleError = class extends Error {
25
+ kind = "Server";
26
+ status = 500;
27
+ name = "UnknownValidationRuleError";
28
+ constructor(name) {
29
+ super(`Validation rule "${name}" is not registered. Register it with ValidationFactory.extend() first.`);
30
+ }
31
+ };
32
+ var InvalidRuleParameterError = class extends Error {
33
+ kind = "Server";
34
+ status = 500;
35
+ name = "InvalidRuleParameterError";
36
+ constructor(rule, parameter, reason) {
37
+ super(`Validation rule "${rule}" has an invalid parameter "${parameter}": ${reason}`);
38
+ }
39
+ };
40
+
41
+ // FormRequest.ts
42
+ import { HttpRequest, ValidationFactory } from "@orieljs/contracts";
43
+ import { inject } from "@orieljs/container";
44
+ var FormRequest = class {
45
+ authorize() {
46
+ return true;
47
+ }
48
+ messages() {
49
+ return {};
50
+ }
51
+ attributes() {
52
+ return {};
53
+ }
54
+ /**
55
+ * `const data = await StorePostRequest.validate()` (spec §4.3). Resolves the current request and the
56
+ * ValidationFactory from the active container, runs authorize() first (throwing AuthorizationErrorImpl if it
57
+ * returns false), then validates request.all() against rules()/messages()/attributes() and returns the typed
58
+ * validated data (or throws the 422 ValidationError — see ValidatorImpl.validated()).
59
+ */
60
+ static async validate() {
61
+ const instance = new this();
62
+ if (!await instance.authorize()) throw new AuthorizationErrorImpl();
63
+ const request = inject(HttpRequest);
64
+ const factory = inject(ValidationFactory);
65
+ const validator = factory.make(request.all(), instance.rules(), instance.messages(), instance.attributes());
66
+ return validator.validated();
67
+ }
68
+ };
69
+
70
+ // messages.ts
71
+ import { Str } from "@orieljs/support";
72
+ function defaultAttributeName(key) {
73
+ return key.split(".").map((segment) => Str.snake(segment).replace(/_/g, " ")).join(".");
74
+ }
75
+ function applyReplacements(template, replacements) {
76
+ return Object.entries(replacements).reduce((message, [key, value]) => message.replaceAll(`:${key}`, value), template);
77
+ }
78
+ var DEFAULT_MESSAGES = {
79
+ required: "The :attribute field is required.",
80
+ required_if: "The :attribute field is required when :other is :value.",
81
+ required_unless: "The :attribute field is required unless :other is in :values.",
82
+ required_with: "The :attribute field is required when :values is present.",
83
+ required_with_all: "The :attribute field is required when :values are present.",
84
+ required_without: "The :attribute field is required when :values is not present.",
85
+ required_without_all: "The :attribute field is required when none of :values are present.",
86
+ string: "The :attribute field must be a string.",
87
+ integer: "The :attribute field must be an integer.",
88
+ numeric: "The :attribute field must be a number.",
89
+ boolean: "The :attribute field must be true or false.",
90
+ array: "The :attribute field must be an array.",
91
+ email: "The :attribute field must be a valid email address.",
92
+ url: "The :attribute field must be a valid URL.",
93
+ uuid: "The :attribute field must be a valid UUID.",
94
+ alpha: "The :attribute field must only contain letters.",
95
+ alpha_num: "The :attribute field must only contain letters and numbers.",
96
+ alpha_dash: "The :attribute field must only contain letters, numbers, dashes and underscores.",
97
+ regex: "The :attribute field format is invalid.",
98
+ confirmed: "The :attribute field confirmation does not match.",
99
+ same: "The :attribute field must match :other.",
100
+ different: "The :attribute field must be different from :other.",
101
+ accepted: "The :attribute field must be accepted.",
102
+ digits: "The :attribute field must be :digits digits.",
103
+ date: "The :attribute field must be a valid date.",
104
+ before: "The :attribute field must be a date before :date.",
105
+ after: "The :attribute field must be a date after :date.",
106
+ in: "The selected :attribute is invalid.",
107
+ not_in: "The selected :attribute is invalid.",
108
+ unique: "The :attribute has already been taken.",
109
+ exists: "The selected :attribute is invalid.",
110
+ file: "The :attribute field must be a file.",
111
+ image: "The :attribute field must be an image.",
112
+ mimes: "The :attribute field must be a file of type: :values.",
113
+ "max.string": "The :attribute field must not be greater than :max characters.",
114
+ "max.numeric": "The :attribute field must not be greater than :max.",
115
+ "max.array": "The :attribute field must not have more than :max items.",
116
+ "max.file": "The :attribute field must not be greater than :max kilobytes.",
117
+ "min.string": "The :attribute field must be at least :min characters.",
118
+ "min.numeric": "The :attribute field must be at least :min.",
119
+ "min.array": "The :attribute field must have at least :min items.",
120
+ "min.file": "The :attribute field must be at least :min kilobytes.",
121
+ "between.string": "The :attribute field must be between :min and :max characters.",
122
+ "between.numeric": "The :attribute field must be between :min and :max.",
123
+ "between.array": "The :attribute field must have between :min and :max items.",
124
+ "between.file": "The :attribute field must be between :min and :max kilobytes.",
125
+ "size.string": "The :attribute field must be :size characters.",
126
+ "size.numeric": "The :attribute field must be :size.",
127
+ "size.array": "The :attribute field must contain :size items.",
128
+ "size.file": "The :attribute field must be :size kilobytes."
129
+ };
130
+
131
+ // MessageBag.ts
132
+ var MessageBagImpl = class {
133
+ items = {};
134
+ /** Not part of the MessageBag contract — used internally by ValidatorImpl and by after() hooks. */
135
+ add(key, message) {
136
+ ;
137
+ (this.items[key] ??= []).push(message);
138
+ }
139
+ has(key) {
140
+ return (this.items[key]?.length ?? 0) > 0;
141
+ }
142
+ first(key) {
143
+ if (key !== void 0) return this.items[key]?.[0];
144
+ for (const messages of Object.values(this.items)) {
145
+ if (messages[0] !== void 0) return messages[0];
146
+ }
147
+ return void 0;
148
+ }
149
+ get(key) {
150
+ return this.items[key] ?? [];
151
+ }
152
+ all() {
153
+ return Object.values(this.items).flat();
154
+ }
155
+ isEmpty() {
156
+ return Object.keys(this.items).length === 0;
157
+ }
158
+ count() {
159
+ return this.all().length;
160
+ }
161
+ toObject() {
162
+ return structuredClone(this.items);
163
+ }
164
+ };
165
+
166
+ // rules/database.ts
167
+ import { DatabaseManager } from "@orieljs/contracts";
168
+ import { inject as inject2 } from "@orieljs/container";
169
+ function guessColumn(attribute, expandedFromWildcard = false) {
170
+ if (!expandedFromWildcard) return attribute;
171
+ return attribute.split(".").filter((segment) => !/^\d+$/.test(segment)).at(-1) ?? attribute;
172
+ }
173
+ function isNoExcept(except) {
174
+ return except === void 0 || except === null || except === "" || except === "NULL";
175
+ }
176
+ async function matchingRecordExists(table, column, value, except, idColumn = "id") {
177
+ const db = inject2(DatabaseManager);
178
+ let query = db.connection().table(table).where(column, value);
179
+ if (!isNoExcept(except)) query = query.where(idColumn, "!=", except);
180
+ return query.exists();
181
+ }
182
+ var databaseRules = {
183
+ unique: {
184
+ validate: async (ctx) => {
185
+ const [table, column, except, idColumn] = ctx.parameters;
186
+ return !await matchingRecordExists(table, column || guessColumn(ctx.attribute, ctx.expandedFromWildcard), ctx.value, except, idColumn || "id");
187
+ },
188
+ messageKey: () => "unique"
189
+ },
190
+ exists: {
191
+ validate: async (ctx) => {
192
+ const [table, column] = ctx.parameters;
193
+ return matchingRecordExists(table, column || guessColumn(ctx.attribute, ctx.expandedFromWildcard), ctx.value);
194
+ },
195
+ messageKey: () => "exists"
196
+ }
197
+ };
198
+
199
+ // parse.ts
200
+ import { Arr } from "@orieljs/support";
201
+ function splitParameters(name, raw) {
202
+ return name === "regex" || name === "not_regex" ? [raw] : raw.split(",");
203
+ }
204
+ function splitOnce(value, separator) {
205
+ const index = value.indexOf(separator);
206
+ return index === -1 ? [value, void 0] : [value.slice(0, index), value.slice(index + 1)];
207
+ }
208
+ function parseRuleSpec(spec) {
209
+ const items = typeof spec === "string" ? spec.split("|") : spec;
210
+ return items.filter((item) => item !== "").map((item) => {
211
+ if (typeof item !== "string") return { kind: "object", rule: item };
212
+ const [name, raw] = splitOnce(item, ":");
213
+ return { kind: "named", name, parameters: raw === void 0 ? [] : splitParameters(name, raw) };
214
+ });
215
+ }
216
+ function isEmptyValue(value) {
217
+ if (value === null || value === void 0) return true;
218
+ if (typeof value === "string") return value === "";
219
+ if (Array.isArray(value)) return value.length === 0;
220
+ return false;
221
+ }
222
+ function isUploadedFileLike(value) {
223
+ return typeof value === "object" && value !== null && typeof value.clientName === "string" && typeof value.arrayBuffer === "function";
224
+ }
225
+ var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
226
+ function expandWildcardKeys(pattern, data) {
227
+ const segments = pattern.split(".");
228
+ let paths = [""];
229
+ for (const segment of segments) {
230
+ const next = [];
231
+ for (const base of paths) {
232
+ const prefix = base === "" ? "" : `${base}.`;
233
+ if (segment !== "*") {
234
+ next.push(`${prefix}${segment}`);
235
+ continue;
236
+ }
237
+ const container = Arr.get(data, base === "" ? void 0 : base);
238
+ if (Array.isArray(container)) {
239
+ container.forEach((_, index) => next.push(`${prefix}${index}`));
240
+ } else if (container !== null && typeof container === "object") {
241
+ Object.keys(container).filter((key) => !UNSAFE_KEYS.has(key)).forEach((key) => next.push(`${prefix}${key}`));
242
+ }
243
+ }
244
+ paths = next;
245
+ }
246
+ return paths;
247
+ }
248
+
249
+ // rules/file.ts
250
+ var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set(["jpg", "jpeg", "png", "bmp", "gif", "svg", "webp"]);
251
+ var fileRules = {
252
+ file: { validate: (ctx) => isUploadedFileLike(ctx.value), messageKey: () => "file" },
253
+ image: {
254
+ validate: (ctx) => {
255
+ if (!isUploadedFileLike(ctx.value)) return false;
256
+ if (ctx.value.mimeType.startsWith("image/")) return true;
257
+ return IMAGE_EXTENSIONS.has(ctx.value.extension().toLowerCase());
258
+ },
259
+ messageKey: () => "image"
260
+ },
261
+ mimes: {
262
+ validate: (ctx) => {
263
+ if (!isUploadedFileLike(ctx.value)) return false;
264
+ const allowed = ctx.parameters.map((p) => p.toLowerCase());
265
+ return allowed.includes(ctx.value.extension().toLowerCase());
266
+ },
267
+ messageKey: () => "mimes",
268
+ replacements: (ctx) => ({ values: ctx.parameters.join(", ") })
269
+ }
270
+ };
271
+
272
+ // rules/format.ts
273
+ import { Arr as Arr2 } from "@orieljs/support";
274
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
275
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
276
+ var ALPHA_RE = /^[A-Za-z]+$/;
277
+ var ALPHA_NUM_RE = /^[A-Za-z0-9]+$/;
278
+ var ALPHA_DASH_RE = /^[A-Za-z0-9_-]+$/;
279
+ var ACCEPTED_VALUES = ["yes", "on", 1, "1", true, "true"];
280
+ function compileRegex(raw) {
281
+ const match = /^\/(.*)\/([a-z]*)$/.exec(raw);
282
+ try {
283
+ return match ? new RegExp(match[1], match[2]) : new RegExp(raw);
284
+ } catch (error) {
285
+ throw new InvalidRuleParameterError("regex", raw, error instanceof Error ? error.message : String(error));
286
+ }
287
+ }
288
+ var DATE_SHAPES = [
289
+ /^\d{4}-\d{1,2}-\d{1,2}(?:[T\s].*)?$/,
290
+ /^\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}(?:\s.*)?$/,
291
+ /^(?=.*\d).*\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i
292
+ ];
293
+ var DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
294
+ function parseDate(value) {
295
+ const trimmed = value.trim();
296
+ if (!DATE_SHAPES.some((shape) => shape.test(trimmed))) return Number.NaN;
297
+ const dateOnly = DATE_ONLY_RE.exec(trimmed);
298
+ if (dateOnly) {
299
+ const [, y, m, d] = dateOnly.map(Number);
300
+ const local = new Date(y, m - 1, d);
301
+ return local.getMonth() === m - 1 && local.getDate() === d ? local.getTime() : Number.NaN;
302
+ }
303
+ return Date.parse(trimmed);
304
+ }
305
+ function startOfDay(offsetDays) {
306
+ const date = /* @__PURE__ */ new Date();
307
+ date.setHours(0, 0, 0, 0);
308
+ date.setDate(date.getDate() + offsetDays);
309
+ return date.getTime();
310
+ }
311
+ var RELATIVE_RE = /^([+-]\d+)\s*(second|minute|hour|day|week|month|year)s?$/i;
312
+ function relativeDate(param) {
313
+ switch (param.trim().toLowerCase()) {
314
+ case "now":
315
+ return Date.now();
316
+ case "today":
317
+ return startOfDay(0);
318
+ case "tomorrow":
319
+ return startOfDay(1);
320
+ case "yesterday":
321
+ return startOfDay(-1);
322
+ }
323
+ const match = RELATIVE_RE.exec(param.trim());
324
+ if (!match) return void 0;
325
+ const amount = Number(match[1]);
326
+ const date = /* @__PURE__ */ new Date();
327
+ switch (match[2].toLowerCase()) {
328
+ case "second":
329
+ date.setSeconds(date.getSeconds() + amount);
330
+ break;
331
+ case "minute":
332
+ date.setMinutes(date.getMinutes() + amount);
333
+ break;
334
+ case "hour":
335
+ date.setHours(date.getHours() + amount);
336
+ break;
337
+ case "day":
338
+ date.setDate(date.getDate() + amount);
339
+ break;
340
+ case "week":
341
+ date.setDate(date.getDate() + amount * 7);
342
+ break;
343
+ case "month":
344
+ date.setMonth(date.getMonth() + amount);
345
+ break;
346
+ case "year":
347
+ date.setFullYear(date.getFullYear() + amount);
348
+ break;
349
+ }
350
+ return date.getTime();
351
+ }
352
+ var FIELD_REFERENCE_RE = /^[A-Za-z_][\w-]*(?:\.[\w-]+)*$/;
353
+ function resolveDateParam(rule, param, data) {
354
+ const fieldValue = Arr2.get(data, param);
355
+ if (typeof fieldValue === "string") {
356
+ const parsed = parseDate(fieldValue);
357
+ if (!Number.isNaN(parsed)) return parsed;
358
+ }
359
+ const relative = relativeDate(param);
360
+ if (relative !== void 0) return relative;
361
+ const literal = parseDate(param);
362
+ if (!Number.isNaN(literal)) return literal;
363
+ if (FIELD_REFERENCE_RE.test(param)) return Number.NaN;
364
+ throw new InvalidRuleParameterError(rule, param, 'expected a date, today/tomorrow/yesterday/now, "+N unit", or a field name');
365
+ }
366
+ function compareDate(ctx, rule) {
367
+ const target = resolveDateParam(rule, ctx.parameters[0] ?? "", ctx.data);
368
+ if (typeof ctx.value !== "string") return false;
369
+ const value = parseDate(ctx.value);
370
+ if (Number.isNaN(value) || Number.isNaN(target)) return false;
371
+ return rule === "before" ? value < target : value > target;
372
+ }
373
+ var formatRules = {
374
+ email: { validate: (ctx) => typeof ctx.value === "string" && EMAIL_RE.test(ctx.value), messageKey: () => "email" },
375
+ url: {
376
+ validate: (ctx) => {
377
+ if (typeof ctx.value !== "string") return false;
378
+ try {
379
+ new URL(ctx.value);
380
+ return true;
381
+ } catch {
382
+ return false;
383
+ }
384
+ },
385
+ messageKey: () => "url"
386
+ },
387
+ uuid: { validate: (ctx) => typeof ctx.value === "string" && UUID_RE.test(ctx.value), messageKey: () => "uuid" },
388
+ alpha: { validate: (ctx) => typeof ctx.value === "string" && ALPHA_RE.test(ctx.value), messageKey: () => "alpha" },
389
+ alpha_num: {
390
+ validate: (ctx) => typeof ctx.value === "string" && ALPHA_NUM_RE.test(ctx.value),
391
+ messageKey: () => "alpha_num"
392
+ },
393
+ alpha_dash: {
394
+ validate: (ctx) => typeof ctx.value === "string" && ALPHA_DASH_RE.test(ctx.value),
395
+ messageKey: () => "alpha_dash"
396
+ },
397
+ regex: {
398
+ validate: (ctx) => typeof ctx.value === "string" && compileRegex(ctx.parameters[0] ?? "").test(ctx.value),
399
+ messageKey: () => "regex"
400
+ },
401
+ confirmed: {
402
+ validate: (ctx) => ctx.value === Arr2.get(ctx.data, `${ctx.attribute}_confirmation`),
403
+ messageKey: () => "confirmed"
404
+ },
405
+ same: {
406
+ validate: (ctx) => ctx.value === Arr2.get(ctx.data, ctx.parameters[0] ?? ""),
407
+ messageKey: () => "same",
408
+ replacements: (ctx) => ({ other: defaultAttributeName(ctx.parameters[0] ?? "") })
409
+ },
410
+ different: {
411
+ validate: (ctx) => ctx.value !== Arr2.get(ctx.data, ctx.parameters[0] ?? ""),
412
+ messageKey: () => "different",
413
+ replacements: (ctx) => ({ other: defaultAttributeName(ctx.parameters[0] ?? "") })
414
+ },
415
+ accepted: { implicit: true, validate: (ctx) => ACCEPTED_VALUES.includes(ctx.value), messageKey: () => "accepted" },
416
+ digits: {
417
+ validate: (ctx) => typeof ctx.value !== "object" && new RegExp(`^\\d{${ctx.parameters[0]}}$`).test(String(ctx.value)),
418
+ messageKey: () => "digits",
419
+ replacements: (ctx) => ({ digits: ctx.parameters[0] ?? "" })
420
+ },
421
+ date: {
422
+ validate: (ctx) => typeof ctx.value === "string" && !Number.isNaN(parseDate(ctx.value)),
423
+ messageKey: () => "date"
424
+ },
425
+ before: {
426
+ validate: (ctx) => compareDate(ctx, "before"),
427
+ messageKey: () => "before",
428
+ replacements: (ctx) => ({ date: ctx.parameters[0] ?? "" })
429
+ },
430
+ after: {
431
+ validate: (ctx) => compareDate(ctx, "after"),
432
+ messageKey: () => "after",
433
+ replacements: (ctx) => ({ date: ctx.parameters[0] ?? "" })
434
+ }
435
+ };
436
+
437
+ // rules/presence.ts
438
+ import { Arr as Arr3 } from "@orieljs/support";
439
+ function isFieldPresent(field, data) {
440
+ return Arr3.has(data, field) && !isEmptyValue(Arr3.get(data, field));
441
+ }
442
+ function anyPresent(fields, data) {
443
+ return fields.some((field) => isFieldPresent(field, data));
444
+ }
445
+ function allPresent(fields, data) {
446
+ return fields.every((field) => isFieldPresent(field, data));
447
+ }
448
+ var presenceRules = {
449
+ required: {
450
+ implicit: true,
451
+ validate: (ctx) => !isEmptyValue(ctx.value),
452
+ messageKey: () => "required"
453
+ },
454
+ required_if: {
455
+ implicit: true,
456
+ validate: (ctx) => {
457
+ const [other, ...values] = ctx.parameters;
458
+ const applies = values.includes(String(Arr3.get(ctx.data, other)));
459
+ return !applies || !isEmptyValue(ctx.value);
460
+ },
461
+ messageKey: () => "required_if",
462
+ replacements: (ctx) => ({ other: ctx.parameters[0] ?? "", value: ctx.parameters.slice(1).join(", ") })
463
+ },
464
+ required_unless: {
465
+ implicit: true,
466
+ validate: (ctx) => {
467
+ const [other, ...values] = ctx.parameters;
468
+ const applies = !values.includes(String(Arr3.get(ctx.data, other)));
469
+ return !applies || !isEmptyValue(ctx.value);
470
+ },
471
+ messageKey: () => "required_unless",
472
+ replacements: (ctx) => ({ other: ctx.parameters[0] ?? "", values: ctx.parameters.slice(1).join(", ") })
473
+ },
474
+ required_with: {
475
+ implicit: true,
476
+ validate: (ctx) => !anyPresent(ctx.parameters, ctx.data) || !isEmptyValue(ctx.value),
477
+ messageKey: () => "required_with",
478
+ replacements: (ctx) => ({ values: ctx.parameters.join(" / ") })
479
+ },
480
+ required_with_all: {
481
+ implicit: true,
482
+ validate: (ctx) => !allPresent(ctx.parameters, ctx.data) || !isEmptyValue(ctx.value),
483
+ messageKey: () => "required_with_all",
484
+ replacements: (ctx) => ({ values: ctx.parameters.join(" / ") })
485
+ },
486
+ required_without: {
487
+ implicit: true,
488
+ validate: (ctx) => ctx.parameters.every((f) => isFieldPresent(f, ctx.data)) || !isEmptyValue(ctx.value),
489
+ messageKey: () => "required_without",
490
+ replacements: (ctx) => ({ values: ctx.parameters.join(" / ") })
491
+ },
492
+ required_without_all: {
493
+ implicit: true,
494
+ validate: (ctx) => anyPresent(ctx.parameters, ctx.data) || !isEmptyValue(ctx.value),
495
+ messageKey: () => "required_without_all",
496
+ replacements: (ctx) => ({ values: ctx.parameters.join(" / ") })
497
+ }
498
+ };
499
+
500
+ // rules/type.ts
501
+ var BOOLEAN_VALUES = [true, false, 0, 1, "0", "1"];
502
+ var NUMERIC_STRING_RE = /^\s*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\s*$/;
503
+ function isNumeric(value) {
504
+ if (typeof value === "number") return Number.isFinite(value);
505
+ return typeof value === "string" && NUMERIC_STRING_RE.test(value);
506
+ }
507
+ var typeRules = {
508
+ string: { validate: (ctx) => typeof ctx.value === "string", messageKey: () => "string" },
509
+ integer: {
510
+ validate: (ctx) => typeof ctx.value !== "object" && /^-?\d+$/.test(String(ctx.value).trim()),
511
+ messageKey: () => "integer"
512
+ },
513
+ numeric: {
514
+ validate: (ctx) => isNumeric(ctx.value),
515
+ messageKey: () => "numeric"
516
+ },
517
+ boolean: { validate: (ctx) => BOOLEAN_VALUES.includes(ctx.value), messageKey: () => "boolean" },
518
+ array: { validate: (ctx) => Array.isArray(ctx.value), messageKey: () => "array" }
519
+ };
520
+
521
+ // rules/size.ts
522
+ function hasAny(ctx, names) {
523
+ return names.some((name) => ctx.ruleNames.includes(name));
524
+ }
525
+ function isPlainObject(value) {
526
+ if (typeof value !== "object" || value === null) return false;
527
+ const proto = Object.getPrototypeOf(value);
528
+ return proto === Object.prototype || proto === null;
529
+ }
530
+ function measure(ctx) {
531
+ const { value } = ctx;
532
+ if (hasAny(ctx, ["numeric", "integer"])) {
533
+ return { type: "numeric", size: isNumeric(value) ? Number(value) : stringLength(value) };
534
+ }
535
+ if (Array.isArray(value)) return { type: "array", size: value.length };
536
+ if (isUploadedFileLike(value)) return { type: "file", size: value.size / 1024 };
537
+ if (hasAny(ctx, ["file", "image"])) return { type: "file", size: Number.NaN };
538
+ if (isPlainObject(value)) return { type: "array", size: Object.keys(value).length };
539
+ if (hasAny(ctx, ["array"])) return { type: "array", size: stringLength(value) };
540
+ return { type: "string", size: stringLength(value) };
541
+ }
542
+ function stringLength(value) {
543
+ if (value === null || value === void 0) return 0;
544
+ return [...String(value)].length;
545
+ }
546
+ function sizeOf(ctx) {
547
+ return measure(ctx).size;
548
+ }
549
+ function sizeType(ctx) {
550
+ return measure(ctx).type;
551
+ }
552
+ var sizeRules = {
553
+ max: {
554
+ validate: (ctx) => sizeOf(ctx) <= Number(ctx.parameters[0]),
555
+ messageKey: (ctx) => `max.${sizeType(ctx)}`,
556
+ replacements: (ctx) => ({ max: ctx.parameters[0] ?? "" })
557
+ },
558
+ min: {
559
+ validate: (ctx) => sizeOf(ctx) >= Number(ctx.parameters[0]),
560
+ messageKey: (ctx) => `min.${sizeType(ctx)}`,
561
+ replacements: (ctx) => ({ min: ctx.parameters[0] ?? "" })
562
+ },
563
+ between: {
564
+ validate: (ctx) => {
565
+ const size = sizeOf(ctx);
566
+ return size >= Number(ctx.parameters[0]) && size <= Number(ctx.parameters[1]);
567
+ },
568
+ messageKey: (ctx) => `between.${sizeType(ctx)}`,
569
+ replacements: (ctx) => ({ min: ctx.parameters[0] ?? "", max: ctx.parameters[1] ?? "" })
570
+ },
571
+ size: {
572
+ validate: (ctx) => sizeOf(ctx) === Number(ctx.parameters[0]),
573
+ messageKey: (ctx) => `size.${sizeType(ctx)}`,
574
+ replacements: (ctx) => ({ size: ctx.parameters[0] ?? "" })
575
+ },
576
+ in: { validate: (ctx) => ctx.parameters.includes(String(ctx.value)), messageKey: () => "in" },
577
+ not_in: { validate: (ctx) => !ctx.parameters.includes(String(ctx.value)), messageKey: () => "not_in" }
578
+ };
579
+
580
+ // registry.ts
581
+ var builtinRules = {
582
+ ...presenceRules,
583
+ ...typeRules,
584
+ ...formatRules,
585
+ ...sizeRules,
586
+ ...fileRules,
587
+ ...databaseRules
588
+ };
589
+
590
+ // Rule.ts
591
+ var BuiltinRuleObject = class {
592
+ // Declares all 4 contract parameters so a subclass's inferred type (from `Rule.in(...)` etc.) still accepts a
593
+ // call with the full 4-arg ValidationRule.validate signature, as Rule.test.ts makes.
594
+ async validate(attribute, value, fail, data, expandedFromWildcard = false) {
595
+ if (await this.passes(attribute, value, data, expandedFromWildcard)) return;
596
+ fail(applyReplacements(DEFAULT_MESSAGES[this.ruleName], { attribute: defaultAttributeName(attribute) }));
597
+ }
598
+ };
599
+ var InRule = class extends BuiltinRuleObject {
600
+ constructor(values) {
601
+ super();
602
+ this.values = values;
603
+ }
604
+ values;
605
+ ruleName = "in";
606
+ passes(_attribute, value) {
607
+ return this.values.map(String).includes(String(value));
608
+ }
609
+ };
610
+ function isModelInstance(value) {
611
+ return typeof value === "object" && value !== null && typeof value.getKey === "function" && typeof value.getKeyName === "function";
612
+ }
613
+ var UniqueRule = class extends BuiltinRuleObject {
614
+ constructor(table, column) {
615
+ super();
616
+ this.table = table;
617
+ this.column = column;
618
+ }
619
+ table;
620
+ column;
621
+ ruleName = "unique";
622
+ exceptId;
623
+ idColumnName;
624
+ /**
625
+ * Excludes the current record from the uniqueness check. Laravel's `Rule::unique('users')->ignore($user)` accepts
626
+ * a model directly (the common case: "update your profile without changing your email"), so a model instance is
627
+ * bound by its key (`getKey()`/`getKeyName()`), not the object itself — the object would reach the query builder
628
+ * as an opaque value and either be rejected by the driver or stringified (B27). A plain id still works as before;
629
+ * null (or "NULL") excludes nothing.
630
+ */
631
+ ignore(id, idColumn) {
632
+ if (isModelInstance(id)) {
633
+ this.exceptId = id.getKey();
634
+ this.idColumnName = idColumn ?? id.getKeyName();
635
+ } else {
636
+ this.exceptId = id;
637
+ this.idColumnName = idColumn ?? "id";
638
+ }
639
+ return this;
640
+ }
641
+ async passes(attribute, value, _data, expandedFromWildcard = false) {
642
+ const column = this.column ?? guessColumn(attribute, expandedFromWildcard);
643
+ return !await matchingRecordExists(this.table, column, value, this.exceptId, this.idColumnName ?? "id");
644
+ }
645
+ };
646
+ var ExistsRule = class extends BuiltinRuleObject {
647
+ constructor(table, column) {
648
+ super();
649
+ this.table = table;
650
+ this.column = column;
651
+ }
652
+ table;
653
+ column;
654
+ ruleName = "exists";
655
+ passes(attribute, value, _data, expandedFromWildcard = false) {
656
+ return matchingRecordExists(this.table, this.column ?? guessColumn(attribute, expandedFromWildcard), value);
657
+ }
658
+ };
659
+ var Rule = {
660
+ in: (values) => new InRule(values),
661
+ unique: (table, column) => new UniqueRule(table, column),
662
+ exists: (table, column) => new ExistsRule(table, column)
663
+ };
664
+
665
+ // Validator.ts
666
+ import { Arr as Arr4 } from "@orieljs/support";
667
+ var SKIPPED_MODIFIERS = /* @__PURE__ */ new Set(["bail", "nullable", "sometimes"]);
668
+ function buildNested(flat) {
669
+ const root = {};
670
+ const created = /* @__PURE__ */ new Set([root]);
671
+ for (const [path, value] of Object.entries(flat)) {
672
+ const parts = path.split(".");
673
+ if (parts.some((part) => UNSAFE_KEYS.has(part))) continue;
674
+ let current = root;
675
+ for (const part of parts.slice(0, -1)) {
676
+ const existing = Object.hasOwn(current, part) ? current[part] : void 0;
677
+ if (typeof existing === "object" && existing !== null && created.has(existing)) {
678
+ current = existing;
679
+ continue;
680
+ }
681
+ const container = cloneContainer(existing);
682
+ created.add(container);
683
+ current[part] = container;
684
+ current = container;
685
+ }
686
+ current[parts.at(-1)] = value;
687
+ }
688
+ return arrayify(root, created);
689
+ }
690
+ function isPlainContainer(value) {
691
+ if (Array.isArray(value)) return true;
692
+ if (typeof value !== "object" || value === null) return false;
693
+ const proto = Object.getPrototypeOf(value);
694
+ return proto === Object.prototype || proto === null;
695
+ }
696
+ function cloneContainer(existing) {
697
+ const container = {};
698
+ if (!isPlainContainer(existing)) return container;
699
+ for (const [key, value] of Object.entries(existing)) {
700
+ if (!UNSAFE_KEYS.has(key)) container[key] = value;
701
+ }
702
+ return container;
703
+ }
704
+ function arrayify(node, created) {
705
+ if (node === null || typeof node !== "object" || !created.has(node)) return node;
706
+ const entries = Object.entries(node).map(([k, v]) => [k, arrayify(v, created)]);
707
+ const keys = entries.map(([k]) => k);
708
+ const isArrayLike = keys.length > 0 && keys.every((k, i) => k === String(i));
709
+ return isArrayLike ? entries.map(([, v]) => v) : Object.fromEntries(entries);
710
+ }
711
+ var ValidatorImpl = class {
712
+ constructor(data, rules, customMessages, customAttributes, registry, errorBagName = "default") {
713
+ this.data = data;
714
+ this.rules = rules;
715
+ this.customMessages = customMessages;
716
+ this.customAttributes = customAttributes;
717
+ this.registry = registry;
718
+ this.errorBagName = errorBagName;
719
+ }
720
+ data;
721
+ rules;
722
+ customMessages;
723
+ customAttributes;
724
+ registry;
725
+ errorBagName;
726
+ bag;
727
+ ranPromise;
728
+ afterCallbacks = [];
729
+ after(callback) {
730
+ this.afterCallbacks.push(callback);
731
+ return this;
732
+ }
733
+ /** Not part of the Validator contract — lets an after() hook add a failure (MessageBag itself is read-only). */
734
+ fail(field, message) {
735
+ this.bag ??= new MessageBagImpl();
736
+ this.bag.add(field, message);
737
+ }
738
+ async passes() {
739
+ this.ranPromise ??= this.run();
740
+ return this.ranPromise;
741
+ }
742
+ async fails() {
743
+ return !await this.passes();
744
+ }
745
+ errors() {
746
+ return this.bag ?? new MessageBagImpl();
747
+ }
748
+ async validated() {
749
+ if (!await this.passes()) throw new ValidationErrorImpl(this.bag.toObject(), this.errorBagName);
750
+ const flat = {};
751
+ for (const pattern of Object.keys(this.rules)) {
752
+ for (const field of this.fieldsFor(pattern)) {
753
+ if (Arr4.has(this.data, field)) flat[field] = Arr4.get(this.data, field);
754
+ }
755
+ }
756
+ return buildNested(flat);
757
+ }
758
+ fieldsFor(pattern) {
759
+ return pattern.includes("*") ? expandWildcardKeys(pattern, this.data) : [pattern];
760
+ }
761
+ async run() {
762
+ this.bag = new MessageBagImpl();
763
+ for (const [pattern, spec] of Object.entries(this.rules)) {
764
+ const parsed = parseRuleSpec(spec);
765
+ const names = parsed.filter((r) => r.kind === "named").map((r) => r.name);
766
+ const bail = names.includes("bail");
767
+ const nullable = names.includes("nullable");
768
+ const sometimes = names.includes("sometimes");
769
+ for (const field of this.fieldsFor(pattern)) {
770
+ const present = Arr4.has(this.data, field);
771
+ if (sometimes && !present) continue;
772
+ const value = Arr4.get(this.data, field);
773
+ const isNull = value === null || value === void 0;
774
+ const isBlank = typeof value === "string" && value.trim() === "";
775
+ for (const rule of parsed) {
776
+ if (rule.kind === "named" && SKIPPED_MODIFIERS.has(rule.name)) continue;
777
+ const implicit = rule.kind === "named" && (this.registry[rule.name]?.implicit ?? false);
778
+ if (!implicit && (!present || isBlank)) continue;
779
+ if (!implicit && nullable && isNull) continue;
780
+ const message = await this.runRule(rule, field, pattern, value, names);
781
+ if (message !== void 0) {
782
+ this.bag.add(field, message);
783
+ if (bail || implicit) break;
784
+ }
785
+ }
786
+ }
787
+ }
788
+ for (const callback of this.afterCallbacks) await callback(this);
789
+ return this.bag.isEmpty();
790
+ }
791
+ async runRule(rule, field, pattern, value, ruleNames) {
792
+ const expanded = pattern.includes("*");
793
+ if (rule.kind === "object") {
794
+ if (rule.rule instanceof BuiltinRuleObject) {
795
+ if (await rule.rule.passes(field, value, this.data, expanded)) return void 0;
796
+ const ctx2 = { attribute: field, expandedFromWildcard: expanded, value, parameters: [], data: this.data, ruleNames };
797
+ return this.failureMessage(rule.rule.ruleName, this.registry[rule.rule.ruleName], ctx2, pattern);
798
+ }
799
+ let failure;
800
+ await rule.rule.validate(field, value, (message) => {
801
+ failure = message;
802
+ }, this.data);
803
+ return failure;
804
+ }
805
+ const builtin = this.registry[rule.name];
806
+ if (!builtin) throw new UnknownValidationRuleError(rule.name);
807
+ const ctx = { attribute: field, expandedFromWildcard: expanded, value, parameters: rule.parameters, data: this.data, ruleNames };
808
+ if (await builtin.validate(ctx)) return void 0;
809
+ return this.failureMessage(rule.name, builtin, ctx, pattern);
810
+ }
811
+ /** Custom message (concrete field, then wildcard pattern, then rule name) or the default, with :attribute filled. */
812
+ failureMessage(name, builtin, ctx, pattern) {
813
+ const field = ctx.attribute;
814
+ const template = this.customMessages[`${field}.${name}`] ?? this.customMessages[`${pattern}.${name}`] ?? this.customMessages[name] ?? DEFAULT_MESSAGES[builtin?.messageKey(ctx) ?? name] ?? builtin?.defaultMessage ?? "The :attribute field is invalid.";
815
+ const display = this.customAttributes[field] ?? this.customAttributes[pattern] ?? defaultAttributeName(field);
816
+ return applyReplacements(template, { attribute: display, ...builtin?.replacements?.(ctx) ?? {} });
817
+ }
818
+ };
819
+
820
+ // ValidationFactory.ts
821
+ var ValidationFactoryImpl = class {
822
+ extended = {};
823
+ make(data, rules, messages = {}, attributes = {}) {
824
+ return new ValidatorImpl(data, rules, messages, attributes, { ...builtinRules, ...this.extended });
825
+ }
826
+ extend(name, callback, message) {
827
+ this.extended[name] = {
828
+ validate: (ctx) => callback(ctx.attribute, ctx.value, ctx.parameters, ctx.data),
829
+ messageKey: () => name,
830
+ defaultMessage: message ?? "The :attribute field is invalid."
831
+ };
832
+ }
833
+ };
834
+
835
+ // facade.ts
836
+ import { ValidationFactory as ValidationFactoryToken } from "@orieljs/contracts";
837
+ import { facade } from "@orieljs/support";
838
+ var Validator = facade(ValidationFactoryToken);
839
+
840
+ // ValidationServiceProvider.ts
841
+ import { ValidationFactory as ValidationFactoryToken2 } from "@orieljs/contracts";
842
+ import { ServiceProvider } from "@orieljs/support";
843
+ var ValidationServiceProvider = class extends ServiceProvider {
844
+ register() {
845
+ this.app.singleton(ValidationFactoryToken2, () => new ValidationFactoryImpl());
846
+ }
847
+ };
848
+ export {
849
+ AuthorizationErrorImpl,
850
+ BuiltinRuleObject,
851
+ DEFAULT_MESSAGES,
852
+ FormRequest,
853
+ InvalidRuleParameterError,
854
+ MessageBagImpl,
855
+ Rule,
856
+ UnknownValidationRuleError,
857
+ ValidationErrorImpl,
858
+ ValidationFactoryImpl,
859
+ ValidationServiceProvider,
860
+ Validator,
861
+ ValidatorImpl,
862
+ applyReplacements,
863
+ builtinRules,
864
+ defaultAttributeName,
865
+ expandWildcardKeys,
866
+ isEmptyValue,
867
+ isUploadedFileLike,
868
+ parseRuleSpec
869
+ };
870
+ //# sourceMappingURL=index.js.map