From a60bdaaecf2d34febf3e12b159fe13f658968adc Mon Sep 17 00:00:00 2001 From: koyopro Date: Tue, 31 Dec 2024 02:22:39 +0900 Subject: [PATCH 1/5] [accel-record-core] Model.validations --- .changeset/two-tigers-share.md | 6 ++ .../src/model/validations.ts | 55 ++++++++++++++++++- tests/models/model/validation.test-d.ts | 20 +++++++ tests/models/validateSample.ts | 25 ++++++--- 4 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 .changeset/two-tigers-share.md create mode 100644 tests/models/model/validation.test-d.ts diff --git a/.changeset/two-tigers-share.md b/.changeset/two-tigers-share.md new file mode 100644 index 00000000..a3661e62 --- /dev/null +++ b/.changeset/two-tigers-share.md @@ -0,0 +1,6 @@ +--- +"accel-record-core": minor +"accel-record": minor +--- + +Added an interface to define validations for the Model as static properties diff --git a/packages/accel-record-core/src/model/validations.ts b/packages/accel-record-core/src/model/validations.ts index 5f3a758b..d48cc968 100644 --- a/packages/accel-record-core/src/model/validations.ts +++ b/packages/accel-record-core/src/model/validations.ts @@ -45,8 +45,7 @@ export class Validations { isValid(this: T): boolean { this.runBeforeCallbacks("validation"); this.errors.clearAll(); - this.validateAttributes(); - this.validateAssociations(); + this.runValidations(); this.runAfterCallbacks("validation"); return this.errors.isEmpty(); } @@ -83,6 +82,25 @@ export class Validations { return this.isValid(); } + protected runValidations(this: T) { + this.validateWithStaticProps(); + this.validateAttributes(); + this.validateAssociations(); + } + + validateWithStaticProps(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. @@ -135,3 +153,36 @@ export class Validations { } } } + +type ValidateItem = [K | K[], ValidatesOptions] | typeof Validator; + +/** + * 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 const validates = ["CreateInput"] & string>( + klass: T, + list: ValidateItem[] +): ValidateItem[] => { + const base = Object.getPrototypeOf(klass).validations ?? []; + return [...base, ...list]; +}; diff --git a/tests/models/model/validation.test-d.ts b/tests/models/model/validation.test-d.ts new file mode 100644 index 00000000..d647eec4 --- /dev/null +++ b/tests/models/model/validation.test-d.ts @@ -0,0 +1,20 @@ +import { validates } from "accel-record-core/dist/model/validations"; +import { ValidateSampleModel } from "../validateSample"; + +test("validates()", () => { + validates(ValidateSampleModel, [ + ["accepted", { acceptance: true }], + [["key", "size"], { presence: true }], + ["accepted", { acceptance: true }], + ["key", { uniqueness: { scope: ["size", "id"] } }], + + // @ts-expect-error + ["foo", { acceptance: true }], + // @ts-expect-error + [["key", "foo"], { presence: true }], + // @ts-expect-error + ["accepted", { foo: true }], + // @ts-expect-error + ["key", { uniqueness: { scope: ["size", "foo"] } }], + ]); +}); diff --git a/tests/models/validateSample.ts b/tests/models/validateSample.ts index 5c795557..2fb4a02c 100644 --- a/tests/models/validateSample.ts +++ b/tests/models/validateSample.ts @@ -1,4 +1,5 @@ import { Validator } from "accel-record"; +import { validates } from "accel-record-core/dist/model/validations.js"; import { ApplicationRecord } from "./applicationRecord.js"; class MyValidator extends Validator<{ key: string | undefined }> { @@ -9,13 +10,24 @@ class MyValidator extends Validator<{ key: string | undefined }> { } } -export class ValidateSampleModel extends ApplicationRecord { +class ValidateSampleBase extends ApplicationRecord { + static validations: any = validates(this, [MyValidator]); +} + +export class ValidateSampleModel extends ValidateSampleBase { + static validations = validates(this, [ + ["accepted", { acceptance: true }], + [ + "pattern", + { + length: { minimum: 2, maximum: 5 }, + format: { with: /^[a-z]+$/, message: "only allows lowercase letters" }, + }, + ], + [["key", "size"], { presence: true }], + ]); + override validateAttributes() { - 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 }); @@ -24,6 +36,5 @@ export class ValidateSampleModel extends ApplicationRecord { if (this.key && !/^[a-z]$/.test(this.key[0])) { this.errors.add("key", "should start with a lowercase letter"); } - this.validatesWith(new MyValidator(this)); } } From af81b23fd41d744b0cff46a8ad4ab2a675f560b1 Mon Sep 17 00:00:00 2001 From: koyopro Date: Tue, 31 Dec 2024 03:01:20 +0900 Subject: [PATCH 2/5] [accel-record] export validations --- packages/accel-record/package.json | 4 ++++ packages/accel-record/src/validations.ts | 1 + 2 files changed, 5 insertions(+) create mode 100644 packages/accel-record/src/validations.ts diff --git a/packages/accel-record/package.json b/packages/accel-record/package.json index 0bfe4608..fa5235f0 100644 --- a/packages/accel-record/package.json +++ b/packages/accel-record/package.json @@ -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" diff --git a/packages/accel-record/src/validations.ts b/packages/accel-record/src/validations.ts new file mode 100644 index 00000000..7f85badd --- /dev/null +++ b/packages/accel-record/src/validations.ts @@ -0,0 +1 @@ +export * from "accel-record-core/dist/model/validations.js"; From 4763799d89838cd3a95affff3b9a6edf58b7e776 Mon Sep 17 00:00:00 2001 From: koyopro Date: Tue, 31 Dec 2024 03:01:53 +0900 Subject: [PATCH 3/5] [accel-record-core] validates() for FormModel --- .../src/model/validations.ts | 16 ++++++++--- tests/models/model/validation.test-d.ts | 27 +++++++++---------- tests/models/validateFormModel.test.ts | 24 +++++++++++++++++ 3 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 tests/models/validateFormModel.test.ts diff --git a/packages/accel-record-core/src/model/validations.ts b/packages/accel-record-core/src/model/validations.ts index d48cc968..e415f368 100644 --- a/packages/accel-record-core/src/model/validations.ts +++ b/packages/accel-record-core/src/model/validations.ts @@ -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"; @@ -179,10 +179,18 @@ type ValidateItem = [K | K[], ValidatesOptions] | typeof Validator * } * ``` */ -export const validates = ["CreateInput"] & string>( +export function validates["CreateInput"] & string>( klass: T, list: ValidateItem[] -): ValidateItem[] => { +): ValidateItem[]; +export function validates & string>( + klass: T, + list: ValidateItem[] +): ValidateItem[]; +export function validates( + klass: T, + list: ValidateItem[] +): ValidateItem[] { const base = Object.getPrototypeOf(klass).validations ?? []; return [...base, ...list]; -}; +} diff --git a/tests/models/model/validation.test-d.ts b/tests/models/model/validation.test-d.ts index d647eec4..67dab627 100644 --- a/tests/models/model/validation.test-d.ts +++ b/tests/models/model/validation.test-d.ts @@ -2,19 +2,16 @@ import { validates } from "accel-record-core/dist/model/validations"; import { ValidateSampleModel } from "../validateSample"; test("validates()", () => { - validates(ValidateSampleModel, [ - ["accepted", { acceptance: true }], - [["key", "size"], { presence: true }], - ["accepted", { acceptance: true }], - ["key", { uniqueness: { scope: ["size", "id"] } }], - - // @ts-expect-error - ["foo", { acceptance: true }], - // @ts-expect-error - [["key", "foo"], { presence: true }], - // @ts-expect-error - ["accepted", { foo: true }], - // @ts-expect-error - ["key", { uniqueness: { scope: ["size", "foo"] } }], - ]); + 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"] } }]]); }); diff --git a/tests/models/validateFormModel.test.ts b/tests/models/validateFormModel.test.ts new file mode 100644 index 00000000..c27fc2c2 --- /dev/null +++ b/tests/models/validateFormModel.test.ts @@ -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"]); +}); From dc799c27a0fb5f767732debc47aa2f7a38c5f48a Mon Sep 17 00:00:00 2001 From: koyopro Date: Tue, 31 Dec 2024 03:04:44 +0900 Subject: [PATCH 4/5] fixup! [accel-record] export validations --- tests/models/model/validation.test-d.ts | 2 +- tests/models/validateSample.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/models/model/validation.test-d.ts b/tests/models/model/validation.test-d.ts index 67dab627..a813c29f 100644 --- a/tests/models/model/validation.test-d.ts +++ b/tests/models/model/validation.test-d.ts @@ -1,4 +1,4 @@ -import { validates } from "accel-record-core/dist/model/validations"; +import { validates } from "accel-record/validations"; import { ValidateSampleModel } from "../validateSample"; test("validates()", () => { diff --git a/tests/models/validateSample.ts b/tests/models/validateSample.ts index 2fb4a02c..b33649ff 100644 --- a/tests/models/validateSample.ts +++ b/tests/models/validateSample.ts @@ -1,5 +1,5 @@ import { Validator } from "accel-record"; -import { validates } from "accel-record-core/dist/model/validations.js"; +import { validates } from "accel-record/validations"; import { ApplicationRecord } from "./applicationRecord.js"; class MyValidator extends Validator<{ key: string | undefined }> { From 3d43773ef3db31ee0d730341840c2ad2cef9ccf0 Mon Sep 17 00:00:00 2001 From: koyopro Date: Tue, 31 Dec 2024 03:16:14 +0900 Subject: [PATCH 5/5] [accel-record] update README for Validation --- packages/accel-record/README-ja.md | 52 ++++++++++++++++------------ packages/accel-record/README.md | 55 +++++++++++++++++------------- 2 files changed, 61 insertions(+), 46 deletions(-) diff --git a/packages/accel-record/README-ja.md b/packages/accel-record/README-ja.md index 06b522b5..a56aab31 100644 --- a/packages/accel-record/README-ja.md +++ b/packages/accel-record/README-ja.md @@ -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 }]]); } ``` @@ -972,7 +971,8 @@ User.create({ firstName: "" }); // => Error: Failed to create ### バリデーションの定義 -BaseModelの `validateAttributes`メソッドをオーバーライドすることで、バリデーションを定義することができます。 +`validates()`関数を利用しモデルクラスに`validations`プロパティを用意することで、バリデーションを定義することができます。 +または、BaseModelの`validateAttributes()`メソッドをオーバーライドする方法もあります。 ```ts // prisma/schema.prisma @@ -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)); } } @@ -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 }]]); } ``` @@ -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; diff --git a/packages/accel-record/README.md b/packages/accel-record/README.md index 33659144..ef247591 100644 --- a/packages/accel-record/README.md +++ b/packages/accel-record/README.md @@ -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 }]]); } ``` @@ -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"] @@ -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 @@ -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)); } } @@ -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 }]]); } ``` @@ -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;