Skip to content

Commit

Permalink
Merge pull request #94 from koyopro/feature/validations
Browse files Browse the repository at this point in the history
[accel-record] Added an interface to define validations for the Model as static properties
  • Loading branch information
koyopro authored Dec 31, 2024
2 parents 3333c37 + 3d43773 commit 84b5eb6
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 56 deletions.
6 changes: 6 additions & 0 deletions .changeset/two-tigers-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"accel-record-core": minor
"accel-record": minor
---

Added an interface to define validations for the Model as static properties
65 changes: 62 additions & 3 deletions packages/accel-record-core/src/model/validations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Collection } from "../associations/collectionProxy.js";
import { HasManyAssociation } from "../associations/hasManyAssociation.js";
import { HasOneAssociation } from "../associations/hasOneAssociation.js";
import { Model, ModelBase } from "../index.js";
import { FormModel, Model, ModelBase } from "../index.js";
import { Meta } from "../meta.js";
import { Errors } from "../validation/errors.js";
import { AcceptanceOptions, AcceptanceValidator } from "../validation/validator/acceptance.js";
Expand Down Expand Up @@ -45,8 +45,7 @@ export class Validations {
isValid<T extends ModelBase & Validations>(this: T): boolean {
this.runBeforeCallbacks("validation");
this.errors.clearAll();
this.validateAttributes();
this.validateAssociations();
this.runValidations();
this.runAfterCallbacks("validation");
return this.errors.isEmpty();
}
Expand Down Expand Up @@ -83,6 +82,25 @@ export class Validations {
return this.isValid();
}

protected runValidations<T extends ModelBase & Validations>(this: T) {
this.validateWithStaticProps();
this.validateAttributes();
this.validateAssociations();
}

validateWithStaticProps<T extends ModelBase & Validations>(this: T) {
const validations = (this.constructor as any)["validations"];
if (Array.isArray(validations)) {
for (const validation of validations) {
if (typeof validation === "function") {
new validation(this).validate();
} else {
this.validates(validation[0], validation[1]);
}
}
}
}

/**
* Validates the attributes of the model.
* This method can be overridden in derived classes to provide custom attribute validations.
Expand Down Expand Up @@ -135,3 +153,44 @@ export class Validations {
}
}
}

type ValidateItem<T, K> = [K | K[], ValidatesOptions<T>] | typeof Validator<any>;

/**
* Combines the base validations of a given model class with additional validation items.
*
* @returns An array containing the combined base and additional validation items.
* @example
* ```ts
* export class ValidateSampleModel extends ApplicationRecord {
* static validations = validates(this, [
* ["accepted", { acceptance: true }],
* [["key", "size"], { presence: true }],
* MyValidator,
* ]);
* }
*
* class MyValidator extends Validator<{ key: string | undefined }> {
* validate() {
* if (this.record.key === "xs") {
* this.errors.add("key", "should not be xs");
* }
* }
* }
* ```
*/
export function validates<T extends typeof Model, K extends keyof Meta<T>["CreateInput"] & string>(
klass: T,
list: ValidateItem<T, K>[]
): ValidateItem<any, any>[];
export function validates<T extends typeof FormModel, K extends keyof InstanceType<T> & string>(
klass: T,
list: ValidateItem<T, K>[]
): ValidateItem<any, any>[];
export function validates<T, K extends string>(
klass: T,
list: ValidateItem<T, K>[]
): ValidateItem<any, any>[] {
const base = Object.getPrototypeOf(klass).validations ?? [];
return [...base, ...list];
}
52 changes: 30 additions & 22 deletions packages/accel-record/README-ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -930,12 +930,11 @@ newUser.age; // => 20
```ts
// src/models/user.ts
import { validates } from "accel-record/validations";
import { ApplicationRecord } from "./applicationRecord.js";

export class UserModel extends ApplicationRecord {
override validateAttributes() {
this.validates("firstName", { presence: true });
}
static validations = validates(this, [["firstName", { presence: true }]]);
}
```
Expand Down Expand Up @@ -972,7 +971,8 @@ User.create({ firstName: "" }); // => Error: Failed to create
### バリデーションの定義
BaseModelの `validateAttributes`メソッドをオーバーライドすることで、バリデーションを定義することができます。
`validates()`関数を利用しモデルクラスに`validations`プロパティを用意することで、バリデーションを定義することができます。
または、BaseModelの`validateAttributes()`メソッドをオーバーライドする方法もあります。
```ts
// prisma/schema.prisma
Expand All @@ -989,27 +989,35 @@ model ValidateSample {
```ts
// ./models/validateSample.ts
import { Validator } from "accel-record";
import { validates } from "accel-record/validations";
import { ApplicationRecord } from "./applicationRecord.js";

export class ValidateSampleModel extends ApplicationRecord {
// validateAttributesメソッドをオーバーライドして、バリデーションを定義します。
override validateAttributes() {
static validations = validates(this, [
// よく使われるバリデーションは、バリデーションヘルパーを利用して簡単に記述ができます。
this.validates("accepted", { acceptance: true });
this.validates("pattern", {
length: { minimum: 2, maximum: 5 },
format: { with: /^[a-z]+$/, message: "only allows lowercase letters" },
});
this.validates("size", { inclusion: { in: ["small", "medium", "large"] } });
this.validates(["key", "size"], { presence: true });
["accepted", { acceptance: true }],
[
"pattern",
{
length: { minimum: 2, maximum: 5 },
format: { with: /^[a-z]+$/, message: "only allows lowercase letters" },
},
],
["size", { inclusion: { in: ["small", "medium", "large"] } }],
[["key", "size"], { presence: true }],

// カスタムバリデータの利用例
MyValidator,
]);

// validateAttributesメソッドをオーバーライドして、バリデーションを定義することもできます。
override validateAttributes() {
this.validates("key", { uniqueness: true });

// 独自のロジックでバリデーションを行う場合は、 errros.add メソッドを利用してエラーメッセージを追加します。
if (this.key && !/^[a-z]$/.test(this.key[0])) {
this.errors.add("key", "should start with a lowercase letter");
}
// カスタムバリデータの利用例
this.validatesWith(new MyValidator(this));
}
}

Expand Down Expand Up @@ -1197,12 +1205,11 @@ errors.messages.[messageKey]
```
```ts
import { validates } from "accel-record/validations";
import { ApplicationRecord } from "./applicationRecord.js";

class UserModel extends ApplicationRecord {
override validateAttributes() {
this.validates("firstName", { presence: true });
}
static validations = validates(this, [["firstName", { presence: true }]]);
}
```
Expand Down Expand Up @@ -1376,16 +1383,17 @@ Formオブジェクトは、通常のモデルとは切り分けてバリデー
```ts
import { FormModel } from "accel-record";
import { attributes } from "accel-record/attributes";
import { validates } from "accel-record/validations";

class MyForm extends FormModel {
title = attributes.string();
priority = attributes.integer(3);
dueDate = attributes.date();

override validateAttributes() {
this.validates("title", { presence: true });
this.validates("priority", { numericality: { between: [1, 5] } });
}
static validations = validates(this, [
["title", { presence: true }],
["priority", { numericality: { between: [1, 5] } }],
]);

save() {
if (this.isInvalid()) return false;
Expand Down
55 changes: 31 additions & 24 deletions packages/accel-record/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -929,12 +929,11 @@ Here is an example of validation for a model.
```ts
// src/models/user.ts
import { validates } from "accel-record/validations";
import { ApplicationRecord } from "./applicationRecord.js";

export class UserModel extends ApplicationRecord {
override validateAttributes() {
this.validates("firstName", { presence: true });
}
static validations = validates(this, [["firstName", { presence: true }]]);
}
```
Expand All @@ -956,7 +955,7 @@ When using the `save`, `update`, and `create` methods, validation is automatical
```ts
import { User } from "./models/index.js";

// When a validation error occurs, save and update will return false.
// If a validation error occurs, save or update will return false.
const newUser = User.build({ firstName: "" });
newUser.save(); // => false
newUser.errors.fullMessages(); // => ["FirstName can't be blank"]
Expand All @@ -965,13 +964,13 @@ const user = User.first()!;
user.update({ firstName: "" }); // => false
newUser.errors.fullMessages(); // => ["FirstName can't be blank"]

// When a validation error occurs, create will throw an exception.
// If a validation error occurs, create will throw an exception.
User.create({ firstName: "" }); // => Error: Failed to create
```
### Validation Definition
You can define validations by overriding the `validateAttributes` method of the BaseModel.
You can define validations by using the `validates()` function and adding a `validations` property to the model class. Alternatively, you can override the `validateAttributes()` method of the BaseModel.
```ts
// prisma/schema.prisma
Expand All @@ -988,27 +987,35 @@ model ValidateSample {
```ts
// ./models/validateSample.ts
import { Validator } from "accel-record";
import { validates } from "accel-record/validations";
import { ApplicationRecord } from "./applicationRecord.js";

export class ValidateSampleModel extends ApplicationRecord {
// Override the validateAttributes method to define validations.
static validations = validates(this, [
// Common validations can be easily written using validation helpers.
["accepted", { acceptance: true }],
[
"pattern",
{
length: { minimum: 2, maximum: 5 },
format: { with: /^[a-z]+$/, message: "only allows lowercase letters" },
},
],
["size", { inclusion: { in: ["small", "medium", "large"] } }],
[["key", "size"], { presence: true }],

// Example of using a custom validator
MyValidator,
]);

// You can also override the validateAttributes method to define validations.
override validateAttributes() {
// Common validations can be easily defined using validation helpers.
this.validates("accepted", { acceptance: true });
this.validates("pattern", {
length: { minimum: 2, maximum: 5 },
format: { with: /^[a-z]+$/, message: "only allows lowercase letters" },
});
this.validates("size", { inclusion: { in: ["small", "medium", "large"] } });
this.validates(["key", "size"], { presence: true });
this.validates("key", { uniqueness: true });

// If you want to perform custom validation logic, use the errors.add method to add error messages.
if (this.key && !/^[a-z]$/.test(this.key[0])) {
this.errors.add("key", "should start with a lowercase letter");
}
// Example of using a custom validator
this.validatesWith(new MyValidator(this));
}
}

Expand Down Expand Up @@ -1196,12 +1203,11 @@ errors.messages.[messageKey]
```
```ts
import { validates } from "accel-record/validations";
import { ApplicationRecord } from "./applicationRecord.js";

class UserModel extends ApplicationRecord {
override validateAttributes() {
this.validates("firstName", { presence: true });
}
static validations = validates(this, [["firstName", { presence: true }]]);
}
```
Expand Down Expand Up @@ -1375,16 +1381,17 @@ By inheriting from the `FormModel` class, you can define attributes and perform
```ts
import { FormModel } from "accel-record";
import { attributes } from "accel-record/attributes";
import { validates } from "accel-record/validations";

class MyForm extends FormModel {
title = attributes.string();
priority = attributes.integer(3);
dueDate = attributes.date();

override validateAttributes() {
this.validates("title", { presence: true });
this.validates("priority", { numericality: { between: [1, 5] } });
}
static validations = validates(this, [
["title", { presence: true }],
["priority", { numericality: { between: [1, 5] } }],
]);

save() {
if (this.isInvalid()) return false;
Expand Down
4 changes: 4 additions & 0 deletions packages/accel-record/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
"types": "./dist/enums.d.ts",
"default": "./dist/enums.js"
},
"./validations": {
"types": "./dist/validations.d.ts",
"default": "./dist/validations.js"
},
"./search": {
"types": "./dist/search.d.ts",
"default": "./dist/search.js"
Expand Down
1 change: 1 addition & 0 deletions packages/accel-record/src/validations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "accel-record-core/dist/model/validations.js";
17 changes: 17 additions & 0 deletions tests/models/model/validation.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { validates } from "accel-record/validations";
import { ValidateSampleModel } from "../validateSample";

test("validates()", () => {
validates(ValidateSampleModel, [["accepted", { acceptance: true }]]);
validates(ValidateSampleModel, [[["key", "size"], { presence: true }]]);
validates(ValidateSampleModel, [["accepted", { acceptance: true }]]);
validates(ValidateSampleModel, [["key", { uniqueness: { scope: ["size", "id"] } }]]);
// @ts-expect-error
validates(ValidateSampleModel, [["foo", { acceptance: true }]]);
// @ts-expect-error
validates(ValidateSampleModel, [[["key", "foo"], { presence: true }]]);
// @ts-expect-error
validates(ValidateSampleModel, [["accepted", { foo: true }]]);
// @ts-expect-error
validates(ValidateSampleModel, [["key", { uniqueness: { scope: ["size", "foo"] } }]]);
});
24 changes: 24 additions & 0 deletions tests/models/validateFormModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { FormModel } from "accel-record";
import { attributes } from "accel-record/attributes";
import { validates } from "accel-record/validations";

class SampleForm extends FormModel {
static validations = validates(this, [
[
"count",
{
numericality: { between: [0, 10] },
},
],
]);

count = attributes.integer(0);
}

test(".validations", () => {
const form = SampleForm.build({ count: 5 });
expect(form.isValid()).toBe(true);
form.count = 11;
expect(form.isValid()).toBe(false);
expect(form.errors.fullMessages).toEqual(["Count must be between 0 and 10"]);
});
Loading

0 comments on commit 84b5eb6

Please sign in to comment.