feat: make AbstractModuleService create method type-safe (#11216)

This commit is contained in:
Harminder Virk
2025-02-03 21:25:01 +05:30
committed by GitHub
parent 6cd8249a2c
commit 016e332e9b
23 changed files with 677 additions and 35 deletions

View File

@@ -0,0 +1,275 @@
import { expectTypeOf } from "expect-type"
import { model } from "../../dml"
import { MedusaService } from "../medusa-service"
import { InferTypeOf } from "@medusajs/types"
const Blog = model.define("Blog", {
id: model.text(),
title: model.text(),
description: model.text().nullable(),
})
type BlogDTO = {
id: number
title: string
}
type CreateBlogDTO = {
title: string | null
}
const baseRepoMock = {
serialize: jest.fn().mockImplementation((item) => item),
transaction: (task) => task("transactionManager"),
getFreshManager: jest.fn().mockReturnThis(),
}
const containerMock = {
baseRepository: baseRepoMock,
mainModelMockRepository: baseRepoMock,
otherModelMock1Repository: baseRepoMock,
otherModelMock2Repository: baseRepoMock,
mainModelMockService: {
retrieve: jest.fn().mockResolvedValue({ id: "1", name: "Item" }),
list: jest.fn().mockResolvedValue([{ id: "1", name: "Item" }]),
delete: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn().mockResolvedValue([[], {}]),
restore: jest.fn().mockResolvedValue([[], {}]),
},
otherModelMock1Service: {
retrieve: jest.fn().mockResolvedValue({ id: "1", name: "Item" }),
list: jest.fn().mockResolvedValue([{ id: "1", name: "Item" }]),
delete: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn().mockResolvedValue([[], {}]),
restore: jest.fn().mockResolvedValue([[], {}]),
},
otherModelMock2Service: {
retrieve: jest.fn().mockResolvedValue({ id: "1", name: "Item" }),
list: jest.fn().mockResolvedValue([{ id: "1", name: "Item" }]),
delete: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn().mockResolvedValue([[], {}]),
restore: jest.fn().mockResolvedValue([[], {}]),
},
}
describe("Medusa Service typings", () => {
describe("create<Service>", () => {
test("type-hint model properties", () => {
class BlogService extends MedusaService({ Blog }) {}
const blogService = new BlogService(containerMock)
expectTypeOf(blogService.createBlogs).parameters.toEqualTypeOf<
| [
Partial<{
id: string | undefined
title: string | undefined
description: string | null | undefined
}>,
...rest: any[]
]
| [
Partial<{
id: string | undefined
title: string | undefined
description: string | null | undefined
}>[],
...rest: any[]
]
>()
expectTypeOf(blogService.createBlogs).returns.toEqualTypeOf<
Promise<InferTypeOf<typeof Blog>> | Promise<InferTypeOf<typeof Blog>[]>
>()
})
test("type-hint DTO properties", () => {
class BlogService extends MedusaService<{ Blog: { dto: BlogDTO } }>({
Blog,
}) {}
const blogService = new BlogService(containerMock)
expectTypeOf(blogService.createBlogs).parameters.toEqualTypeOf<
| [Partial<BlogDTO>, ...rest: any[]]
| [Partial<BlogDTO>[], ...rest: any[]]
>()
expectTypeOf(blogService.createBlogs).returns.toEqualTypeOf<
Promise<BlogDTO> | Promise<BlogDTO[]>
>()
})
test("type-hint force overridden properties", () => {
class BlogService extends MedusaService<{ Blog: { dto: BlogDTO } }>({
Blog,
}) {
// @ts-expect-error
async createBlogs(_: CreateBlogDTO): Promise<BlogDTO> {
return {} as BlogDTO
}
}
const blogService = new BlogService(containerMock)
expectTypeOf(blogService.createBlogs).parameters.toEqualTypeOf<
[CreateBlogDTO]
>()
expectTypeOf(blogService.createBlogs).returns.toEqualTypeOf<
Promise<BlogDTO>
>()
})
test("define custom DTO for inputs", () => {
class BlogService extends MedusaService<{
Blog: { dto: BlogDTO; inputDto: Omit<BlogDTO, "id"> }
}>({
Blog,
}) {}
const blogService = new BlogService(containerMock)
expectTypeOf(blogService.createBlogs).parameters.toEqualTypeOf<
| [Partial<{ title: string | undefined }>, ...rest: any[]]
| [Partial<{ title: string | undefined }>[], ...rest: any[]]
>()
expectTypeOf(blogService.createBlogs).returns.toEqualTypeOf<
Promise<BlogDTO> | Promise<BlogDTO[]>
>()
})
})
describe("update<Service>", () => {
test("type-hint model properties", () => {
class BlogService extends MedusaService({ Blog }) {}
const blogService = new BlogService(containerMock)
expectTypeOf(blogService.updateBlogs).parameters.toEqualTypeOf<
| [
Partial<{
id: string | undefined
title: string | undefined
description: string | null | undefined
}>,
...rest: any[]
]
| [
(
| Partial<{
id: string | undefined
title: string | undefined
description: string | null | undefined
}>[]
| {
selector: Record<string, any>
data:
| Partial<{
id: string | undefined
title: string | undefined
description: string | null | undefined
}>
| Partial<{
id: string | undefined
title: string | undefined
description: string | null | undefined
}>[]
}
| {
selector: Record<string, any>
data:
| Partial<{
id: string | undefined
title: string | undefined
description: string | null | undefined
}>
| Partial<{
id: string | undefined
title: string | undefined
description: string | null | undefined
}>[]
}[]
),
...rest: any[]
]
>()
expectTypeOf(blogService.updateBlogs).returns.toEqualTypeOf<
Promise<InferTypeOf<typeof Blog>> | Promise<InferTypeOf<typeof Blog>[]>
>()
})
test("type-hint DTO properties", () => {
class BlogService extends MedusaService<{ Blog: { dto: BlogDTO } }>({
Blog,
}) {}
const blogService = new BlogService(containerMock)
expectTypeOf(blogService.updateBlogs).parameters.toEqualTypeOf<
| [Partial<BlogDTO>, ...rest: any[]]
| [
(
| Partial<BlogDTO>[]
| {
selector: Record<string, any>
data: Partial<BlogDTO> | Partial<BlogDTO>[]
}
| {
selector: Record<string, any>
data: Partial<BlogDTO> | Partial<BlogDTO>[]
}[]
),
...rest: any[]
]
>()
expectTypeOf(blogService.updateBlogs).returns.toEqualTypeOf<
Promise<BlogDTO> | Promise<BlogDTO[]>
>()
})
test("type-hint force overridden properties", () => {
class BlogService extends MedusaService<{ Blog: { dto: BlogDTO } }>({
Blog,
}) {
// @ts-expect-error
async updateBlogs(_: string, __: CreateBlogDTO): Promise<BlogDTO> {
return {} as BlogDTO
}
}
const blogService = new BlogService(containerMock)
expectTypeOf(blogService.updateBlogs).parameters.toEqualTypeOf<
[id: string, data: CreateBlogDTO]
>()
expectTypeOf(blogService.updateBlogs).returns.toEqualTypeOf<
Promise<BlogDTO>
>()
})
test("define custom DTO for inputs", () => {
class BlogService extends MedusaService<{
Blog: { dto: BlogDTO; inputDto: Omit<BlogDTO, "id"> }
}>({
Blog,
}) {}
const blogService = new BlogService(containerMock)
expectTypeOf(blogService.updateBlogs).parameters.toEqualTypeOf<
| [Partial<{ title: string | undefined }>, ...rest: any[]]
| [
(
| Partial<{ title: string | undefined }>[]
| {
selector: Record<string, any>
data:
| Partial<{ title: string | undefined }>
| Partial<{ title: string | undefined }>[]
}
| {
selector: Record<string, any>
data:
| Partial<{ title: string | undefined }>
| Partial<{ title: string | undefined }>[]
}[]
),
...rest: any[]
]
>()
expectTypeOf(blogService.createBlogs).returns.toEqualTypeOf<
Promise<BlogDTO> | Promise<BlogDTO[]>
>()
})
})
})

View File

@@ -5,6 +5,7 @@ import {
IDmlEntity,
InferEntityType,
Pluralize,
Prettify,
RestoreReturn,
SoftDeleteReturn,
} from "@medusajs/types"
@@ -29,6 +30,13 @@ export type ModelDTOConfig = {
export type ModelsConfigTemplate = { [key: string]: ModelDTOConfig }
/**
* We do not want the DML DTO to accept auto-managed timestamps
* as part of the input for the "create" and the "update"
* methods
*/
type DMLDTOExcludeProperties = "created_at" | "updated_at" | "deleted_at"
export type ModelConfigurationsToConfigTemplate<T extends ModelEntries> = {
[Key in keyof T]: {
dto: T[Key] extends DmlEntity<any, any>
@@ -36,6 +44,11 @@ export type ModelConfigurationsToConfigTemplate<T extends ModelEntries> = {
: T[Key] extends Constructor<any>
? InstanceType<T[Key]>
: any
inputDto: T[Key] extends DmlEntity<any, any>
? Omit<InferEntityType<T[Key]>, DMLDTOExcludeProperties>
: T[Key] extends Constructor<any>
? InstanceType<T[Key]>
: any
model: T[Key] extends { model: infer MODEL }
? MODEL
: T[Key] extends IDmlEntity<any, any>
@@ -83,6 +96,16 @@ export type ModelEntries<Keys = string> = Record<
| { name?: string; singular?: string; plural?: string }
>
/**
* Returns the input DTO for the servide
*/
type GetServiceInput<ModelConfig extends { dto: any; inputDto?: any }> =
Partial<
[undefined] extends ModelConfig["inputDto"]
? ModelConfig["dto"]
: ModelConfig["inputDto"]
>
export type ExtractKeysFromConfig<ModelsConfig> = ModelsConfig extends {
__empty: any
}
@@ -90,7 +113,7 @@ export type ExtractKeysFromConfig<ModelsConfig> = ModelsConfig extends {
: keyof ModelsConfig
export type AbstractModuleService<
TModelsDtoConfig extends Record<string, any>
TModelsDtoConfig extends Record<string, { dto: any; inputDto?: any }>
> = {
[TModelName in keyof TModelsDtoConfig as `retrieve${ExtractSingularName<
TModelsDtoConfig,
@@ -155,14 +178,41 @@ export type AbstractModuleService<
TModelsDtoConfig,
TModelName
>}`]: {
(...args: any[]): Promise<any>
(
data: Prettify<GetServiceInput<TModelsDtoConfig[TModelName]>>,
...rest: any[]
): Promise<TModelsDtoConfig[TModelName]["dto"]>
(
data: Prettify<GetServiceInput<TModelsDtoConfig[TModelName]>>[],
...rest: any[]
): Promise<TModelsDtoConfig[TModelName]["dto"][]>
}
} & {
[TModelName in keyof TModelsDtoConfig as `update${ExtractPluralName<
TModelsDtoConfig,
TModelName
>}`]: {
(...args: any[]): Promise<any>
(
data: Prettify<GetServiceInput<TModelsDtoConfig[TModelName]>>,
...rest: any[]
): Promise<TModelsDtoConfig[TModelName]["dto"]>
(
dataOrOptions:
| Prettify<GetServiceInput<TModelsDtoConfig[TModelName]>>[]
| {
selector: Record<string, any>
data:
| Prettify<GetServiceInput<TModelsDtoConfig[TModelName]>>
| Prettify<GetServiceInput<TModelsDtoConfig[TModelName]>>[]
}
| {
selector: Record<string, any>
data:
| Prettify<GetServiceInput<TModelsDtoConfig[TModelName]>>
| Prettify<GetServiceInput<TModelsDtoConfig[TModelName]>>[]
}[],
...rest: any[]
): Promise<TModelsDtoConfig[TModelName]["dto"][]>
}
}