diff --git a/README.md b/README.md index 4a329afe..767c004b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ # Accella Project - [Accel Record](./packages/accel-record/) +- [Accel Record Factory](./packages/accel-record-factory/) diff --git a/packages/accel-record-core/src/index.ts b/packages/accel-record-core/src/index.ts index 5576a6df..a67b5f0d 100644 --- a/packages/accel-record-core/src/index.ts +++ b/packages/accel-record-core/src/index.ts @@ -8,6 +8,7 @@ import { Fields } from "./fields.js"; import { ModelMeta } from "./meta.js"; import { Dirty } from "./model/dirty.js"; import { Import } from "./model/import.js"; +import { Serialization } from "./model/serialization.js"; import { Validations } from "./model/validations.js"; import { Persistence } from "./persistence.js"; import { Query } from "./query.js"; @@ -63,6 +64,7 @@ export class Model extends classIncludes( Import, Persistence, Query, + Serialization, Transaction, Validations ) { diff --git a/packages/accel-record-core/src/model/serialization.ts b/packages/accel-record-core/src/model/serialization.ts new file mode 100644 index 00000000..103b0dad --- /dev/null +++ b/packages/accel-record-core/src/model/serialization.ts @@ -0,0 +1,156 @@ +import { BelongsToAssociation } from "../associations/belongsToAssociation.js"; +import { Collection } from "../associations/collectionProxy.js"; +import { HasManyAssociation } from "../associations/hasManyAssociation.js"; +import { HasOneAssociation } from "../associations/hasOneAssociation.js"; +import { Model } from "../index.js"; +import { Meta } from "../meta.js"; + +type RetriveModel = undefined extends T + ? NonNullable + : T extends Collection + ? U + : T; + +type ToUnion = undefined extends T + ? never + : NonNullable[number]; + +type ToHashIncludeResult> = K extends keyof T + ? undefined extends T[K] + ? ToHashResult, O> | undefined // HasOne + : T[K] extends Collection + ? ToHashResult[] // HasMany + : ToHashResult // BelongsTo + : {}; + +type ToHashInclude, T> = O["include"] extends string + ? { + [K in O["include"]]: ToHashIncludeResult; + } + : O["include"] extends ToHashIncludeOption + ? { + [K in keyof O["include"]]: ToHashIncludeResult< + K, + T, + // @ts-ignore + NonNullable + >; + } + : {}; + +type ToHashMethods< + O extends ToHashOptions, + T, +> = undefined extends O["methods"] + ? {} + : { + [K in ToUnion]: T[Extract] extends () => any + ? ReturnType]> + : never; + }; + +export type ToHashResult> = { + [K in undefined extends O["only"] + ? Exclude["OrderInput"], ToUnion> + : ToUnion]: T[Extract]; +} & ToHashInclude & + ToHashMethods; + +type ToHashIncludeOption = { + [K in Meta["AssociationKey"]]?: K extends keyof T + ? ToHashOptions> + : never; +}; + +type NoArgMethods = { + [K in keyof T]: T[K] extends () => any ? K : never; +}[keyof T]; + +export type ToHashOptions = { + only?: (keyof Meta["OrderInput"])[]; + except?: (keyof Meta["OrderInput"])[]; + methods?: NoArgMethods[]; + include?: Meta["AssociationKey"] | ToHashIncludeOption; +}; + +/** + * Represents a Serialization class that provides methods for converting a model instance to a hash object. + * + * This class is intended to be inherited by the Model class. + */ +export class Serialization { + /** + * Converts the model instance to a hash object. + * @param options - The options for the conversion. + * @returns The hash object representing the model instance. + */ + toHash>( + this: T, + options?: O + ): ToHashResult; + toHash>( + this: T, + options: O = {} as O + ) { + const ret = {} as any; + for (const field of this.columnFields) { + if (options.only && !options.only.includes(field.name)) continue; + if (options.except && options.except.includes(field.name)) continue; + + ret[field.name] = this[field.dbName as keyof T] ?? undefined; + } + if (typeof options.include === "string") { + ret[options.include] = this.toHashInclude(options.include, {}); + } + if (typeof options.include === "object") { + for (const [key, value] of Object.entries(options.include)) { + ret[key] = this.toHashInclude(key, value ?? {}); + } + } + for (const method of options.methods ?? []) { + const f = this[method as keyof T]; + if (typeof f !== "function") continue; + ret[method] = f.bind(this)(); + } + return ret; + } + + /** + * Converts the associated model instance to a hash object. + * @param key - The key of the association. + * @param options - The options for the conversion. + * @returns The hash object representing the associated model instance. + */ + protected toHashInclude( + this: T, + key: string, + options: ToHashOptions + ) { + const association = this.associations.get(key); + if (association instanceof HasManyAssociation) { + return (this[key as keyof T] as any).map((r: Model) => r.toHash(options)); + } + if ( + association instanceof HasOneAssociation || + association instanceof BelongsToAssociation + ) { + return (this[key as keyof T] as Model)?.toHash(options); + } + return undefined; + } + + /** + * Converts the model instance to a JSON string representation. + * + * @typeparam T - The type of the model. + * @typeparam O - The type of the options for serialization. + * @param options - The options for serialization. + * @returns The JSON string representation of the model instance. + */ + toJson>( + this: T, + options?: O + ): string { + return JSON.stringify(this.toHash(options)); + } +} diff --git a/packages/accel-record-core/src/query.ts b/packages/accel-record-core/src/query.ts index d45b4707..e2fe8f14 100644 --- a/packages/accel-record-core/src/query.ts +++ b/packages/accel-record-core/src/query.ts @@ -105,6 +105,7 @@ export class Query { ...attributes: F // @ts-ignore ): Relation<{ [K in F[number]]: InstanceType[K] }, Meta> { + // @ts-ignore return this.all().select(...attributes); } diff --git a/packages/accel-record-core/src/relation/index.ts b/packages/accel-record-core/src/relation/index.ts index 88c6285d..70259ca0 100644 --- a/packages/accel-record-core/src/relation/index.ts +++ b/packages/accel-record-core/src/relation/index.ts @@ -1,5 +1,6 @@ import { Model } from "../index.js"; import { type ModelMeta } from "../meta.js"; +import { ToHashOptions, ToHashResult } from "../model/serialization.js"; import { classIncludes } from "../utils.js"; import { Association } from "./association.js"; import { RelationBase } from "./base.js"; @@ -41,6 +42,34 @@ export class Relation extends classIncludes( return (this.cache ||= this.load()); } + /** + * Converts the relation to an array of hash objects. + * + * @param options - The options for the conversion. + * @returns An array of hash objects. + */ + toHashArray>( + options?: O + ): T extends Model ? ToHashResult[] : never; + toHashArray(options = {}): Record[] { + return this.toArray().map((row) => + row instanceof Model ? row.toHash(options) : {} + ); + } + + /** + * Converts the relation to a JSON string representation. + * + * @param options - The options for the conversion. + * @returns A JSON string representation of the relation. + */ + toJson>( + options?: O + ): T extends Model ? string : never; + toJson(options = {}): string { + return JSON.stringify(this.toHashArray(options)); + } + /** * Applies a function to each element in the relation and returns an array of the results. * diff --git a/packages/accel-record/README-ja.md b/packages/accel-record/README-ja.md index 3fa7baa2..2e4b3310 100644 --- a/packages/accel-record/README-ja.md +++ b/packages/accel-record/README-ja.md @@ -915,6 +915,46 @@ class MyValidator extends Validator<{ key: string | undefined }> { } ``` +## Serialization + +`toHash`メソッドや`toHashArray`メソッドを利用することで、モデルのデータをプレーンなオブジェクトに変換することができます。 + +```ts +import { User } from "./models/index.js"; + +const userHash = User.first()!.toHash({ + only: ["firstName", "lastName"], + include: { posts: { only: ["title"] } }, +}); +console.log(userHash); +// => { firstName: "John", lastName: "Doe", posts: [{ title: "Hello" }] } + +const usersHashArray = User.all().toHashArray({ + only: ["firstName", "lastName"], +}); +console.log(usersHashArray); +// => [{ firstName: "John", lastName: "Doe" }, { firstName: "Alice", lastName: "Smith" }] +``` + +`toJson`メソッドを利用することで、モデルのデータをJSON文字列に変換することができます。 + +```ts +import { User } from "./models/index.js"; + +const userJson = User.first()!.toHah({ + only: ["firstName", "lastName"], + include: { posts: { only: ["title"] } }, +}); +console.log(userJson); +// => {"firstName":"John","lastName":"Doe","posts":[{"title":"Hello"}]} + +const usersJson = User.all().toHashArray({ + only: ["firstName", "lastName"], +}); +console.log(usersJson); +// => [{"firstName":"John","lastName":"Doe"},{"firstName":"Alice","lastName":"Smith"}] +``` + ## Bulk Insert Bulk Insertは、一度に複数のレコードをデータベースに挿入する機能です。 \ diff --git a/packages/accel-record/README.md b/packages/accel-record/README.md index bee5f671..c7de6533 100644 --- a/packages/accel-record/README.md +++ b/packages/accel-record/README.md @@ -914,6 +914,46 @@ class MyValidator extends Validator<{ key: string | undefined }> { } ``` +## Serialization + +By using the `toHash` and `toHashArray` methods, you can convert the model's data into plain objects. + +```ts +import { User } from "./models/index.js"; + +const userHash = User.first()!.toHash({ + only: ["firstName", "lastName"], + include: { posts: { only: ["title"] } }, +}); +console.log(userHash); +// => { firstName: "John", lastName: "Doe", posts: [{ title: "Hello" }] } + +const usersHashArray = User.all().toHashArray({ + only: ["firstName", "lastName"], +}); +console.log(usersHashArray); +// => [{ firstName: "John", lastName: "Doe" }, { firstName: "Alice", lastName: "Smith" }] +``` + +By using the `toJson` method, you can convert the model's data into a JSON string. + +```ts +import { User } from "./models/index.js"; + +const userJson = User.first()!.toHah({ + only: ["firstName", "lastName"], + include: { posts: { only: ["title"] } }, +}); +console.log(userJson); +// => {"firstName":"John","lastName":"Doe","posts":[{"title":"Hello"}]} + +const usersJson = User.all().toHashArray({ + only: ["firstName", "lastName"], +}); +console.log(usersJson); +// => [{"firstName":"John","lastName":"Doe"},{"firstName":"Alice","lastName":"Smith"}] +``` + ## Bulk Insert Bulk Insert is a feature that allows you to insert multiple records into the database at once. \ diff --git a/tests/models/relation/serialization.test-d.ts b/tests/models/relation/serialization.test-d.ts new file mode 100644 index 00000000..317bca8e --- /dev/null +++ b/tests/models/relation/serialization.test-d.ts @@ -0,0 +1,16 @@ +import { User } from ".."; + +test("Relation#toHashArray()", () => { + const users = User.all().toHashArray(); + expectTypeOf(users[0]).toEqualTypeOf<{ + id: number; + name: string | undefined; + age: number | undefined; + email: string; + createdAt: Date; + updatedAt: Date; + }>(); + + const withSelect = User.select("id").toHashArray(); + expectTypeOf(withSelect).toEqualTypeOf(); +}); diff --git a/tests/models/relation/serialization.test.ts b/tests/models/relation/serialization.test.ts new file mode 100644 index 00000000..da753738 --- /dev/null +++ b/tests/models/relation/serialization.test.ts @@ -0,0 +1,49 @@ +import { User } from ".."; +import { $user } from "../../factories/user"; + +const objectContaining = expect.objectContaining; + +test("Relation#toHashArray()", () => { + $user.create({ name: "hoge" }); + $user.create({ name: "fuga", age: 20 }); + + const users = User.all().toHashArray(); + expect(users).toHaveLength(2); + expect(users[0]).toEqual( + objectContaining({ + name: "hoge", + age: undefined, + }) + ); + expect(users[1]).toEqual( + objectContaining({ + name: "fuga", + age: 20, + }) + ); + + expect(User.all().toHashArray({ only: ["name", "age"] })[0]).toEqual({ + name: "hoge", + age: undefined, + }); +}); + +test("Relation#toJson()", () => { + const now = new Date("Tue, 01 Dec 2024 10:00:00 GMT"); + $user.create({ name: "hoge", createdAt: now }); + $user.create({ name: "fuga", createdAt: now }); + + const result = User.all().toJson({ only: ["name", "createdAt"] }); + expect(result).toEqual( + JSON.stringify([ + { + name: "hoge", + createdAt: "2024-12-01T10:00:00.000Z", + }, + { + name: "fuga", + createdAt: "2024-12-01T10:00:00.000Z", + }, + ]) + ); +}); diff --git a/tests/models/serialization.test-d.ts b/tests/models/serialization.test-d.ts new file mode 100644 index 00000000..d0109388 --- /dev/null +++ b/tests/models/serialization.test-d.ts @@ -0,0 +1,86 @@ +import { $user } from "../factories/user"; + +test("toHash", () => { + const u = $user.create(); + + expectTypeOf(u.toHash()).toEqualTypeOf<{ + id: number; + name: string | undefined; + age: number | undefined; + email: string; + createdAt: Date; + updatedAt: Date; + }>(); + + expectTypeOf(u.toHash({ only: ["name", "age"] })).toEqualTypeOf<{ + name: string | undefined; + age: number | undefined; + }>(); + + expectTypeOf( + u.toHash({ except: ["createdAt", "updatedAt", "email"] }) + ).toEqualTypeOf<{ + id: number; + name: string | undefined; + age: number | undefined; + }>(); + + const withMethods = u.toHash({ only: ["name"], methods: ["isPersisted"] }); + expectTypeOf(withMethods.name).toEqualTypeOf(); + expectTypeOf(withMethods.isPersisted).toEqualTypeOf(); +}); + +test("toHash with include", () => { + const u = $user.create(); + + { + const setting = u.toHash({ include: "setting" }).setting!; + + expectTypeOf(setting.threshold).toEqualTypeOf(); + // @ts-expect-error + u1.posts; + + const posts = u.toHash({ include: "posts" }).posts; + expectTypeOf(posts[0].title).toEqualTypeOf(); + } + + { + const { setting, posts } = u.toHash({ + include: { setting: { only: ["threshold"] }, posts: { only: ["title"] } }, + }); + + expectTypeOf(setting).toEqualTypeOf< + | { + threshold: number | undefined; + } + | undefined + >(); + + expectTypeOf(posts).toEqualTypeOf< + { + title: string; + }[] + >(); + } +}); + +test("toHash with include belongsTo", () => { + const u = $user.create(); + + { + const user = u.setting!.toHash({ include: "user" }).user; + + expectTypeOf(user.name).toEqualTypeOf(); + expectTypeOf(user.id).toEqualTypeOf(); + } + + { + const user = u.setting!.toHash({ + include: { user: { only: ["name"] } }, + }).user; + + expectTypeOf(user.name).toEqualTypeOf(); + // @ts-expect-error + user.id; + } +}); diff --git a/tests/models/serialization.test.ts b/tests/models/serialization.test.ts new file mode 100644 index 00000000..8c345a32 --- /dev/null +++ b/tests/models/serialization.test.ts @@ -0,0 +1,120 @@ +import { $post } from "../factories/post"; +import { $setting } from "../factories/setting"; +import { $user } from "../factories/user"; + +const objectContaining = expect.objectContaining; + +test("toHash", () => { + const u = $user.create({ name: "hoge" }); + expect(u.toHash()).toEqual( + objectContaining({ + id: u.id, + name: "hoge", + age: undefined, + }) + ); + + expect(u.toHash({ only: ["name", "age"] })).toEqual({ + name: "hoge", + age: undefined, + }); + + expect(u.toHash({ except: ["createdAt", "updatedAt", "email"] })).toEqual({ + id: u.id, + name: "hoge", + age: undefined, + }); + + expect(u.toHash({ methods: ["isPersisted"] })).toEqual( + objectContaining({ + id: u.id, + name: "hoge", + age: undefined, + isPersisted: true, + }) + ); +}); + +test("toHash with include", () => { + const u = $user.create({ name: "hoge" }); + $post.create({ title: "post1", authorId: u.id }); + + expect(u.toHash({ include: "setting" })).toEqual( + objectContaining({ + id: u.id, + name: "hoge", + age: undefined, + setting: undefined, + }) + ); + + expect(u.toHash({ include: "posts" })).toEqual( + objectContaining({ + id: u.id, + name: "hoge", + age: undefined, + posts: [objectContaining({ title: "post1", published: false })], + }) + ); + + u.setting = $setting.build({ threshold: 3.0 }); + + expect( + u.toHash({ include: { posts: { only: ["title"] }, setting: {} } }) + ).toEqual( + objectContaining({ + id: u.id, + name: "hoge", + age: undefined, + posts: [{ title: "post1" }], + setting: objectContaining({ threshold: 3.0 }), + }) + ); +}); + +test("toHash with include belongsTo", () => { + const u = $user.create({ + name: "hoge", + setting: $setting.build({ threshold: 3.0 }), + }); + + expect(u.setting?.toHash({ include: "user" })).toEqual( + objectContaining({ + user: objectContaining({ + id: u.id, + name: "hoge", + }), + }) + ); + + expect(u.setting?.toHash({ include: { user: { only: ["name"] } } })).toEqual( + objectContaining({ + user: { name: "hoge" }, + }) + ); +}); + +test("toJson", () => { + const u = $user.create({ name: "hoge" }); + + expect(u.toJson({ only: ["name", "age"] })).toEqual( + JSON.stringify({ + name: "hoge", + age: undefined, + }) + ); + + expect( + u.toJson({ + except: ["createdAt", "updatedAt", "email"], + methods: ["isPersisted"], + }) + ).toEqual( + JSON.stringify({ + id: u.id, + name: "hoge", + age: undefined, + isPersisted: true, + }) + ); +});