feat: add optional fields (#10150)

This commit is contained in:
Harminder Virk
2024-11-19 16:28:33 +05:30
committed by GitHub
parent 18a60e2d2e
commit 661ea7865c
17 changed files with 378 additions and 17 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/types": patch
"@medusajs/utils": patch
---
feat: add optional fields

View File

@@ -62,6 +62,7 @@ export type PropertyMetadata = {
fieldName: string
defaultValue?: any
nullable: boolean
optional: boolean
dataType: {
name: KnownDataTypes
options?: Record<string, any>
@@ -177,23 +178,37 @@ export type InferHasManyFields<Relation> = Relation extends () => IDmlEntity<
*/
export type InferManyToManyFields<Relation> = InferHasManyFields<Relation>
/**
* Only processed property that can be undefined and mark them as optional
*/
export type InferOptionalFields<Schema extends DMLSchema> = Prettify<{
[K in keyof Schema as undefined extends Schema[K]["$dataType"]
? K
: never]?: Schema[K]["$dataType"]
}>
/**
* Inferring the types of the schema fields from the DML
* entity
*/
export type InferSchemaFields<Schema extends DMLSchema> = Prettify<{
[K in keyof Schema]: Schema[K] extends RelationshipType<any>
? Schema[K]["type"] extends "belongsTo"
? InferBelongsToFields<Schema[K]["$dataType"]>
: Schema[K]["type"] extends "hasOne"
? InferHasOneFields<Schema[K]["$dataType"]>
: Schema[K]["type"] extends "hasMany"
? InferHasManyFields<Schema[K]["$dataType"]>
: Schema[K]["type"] extends "manyToMany"
? InferManyToManyFields<Schema[K]["$dataType"]>
: never
: Schema[K]["$dataType"]
}>
export type InferSchemaFields<Schema extends DMLSchema> = Prettify<
{
// Omit optional properties to manage them separately and mark them as optional
[K in keyof Schema as undefined extends Schema[K]["$dataType"]
? never
: K]: Schema[K] extends RelationshipType<any>
? Schema[K]["type"] extends "belongsTo"
? InferBelongsToFields<Schema[K]["$dataType"]>
: Schema[K]["type"] extends "hasOne"
? InferHasOneFields<Schema[K]["$dataType"]>
: Schema[K]["type"] extends "hasMany"
? InferHasManyFields<Schema[K]["$dataType"]>
: Schema[K]["type"] extends "manyToMany"
? InferManyToManyFields<Schema[K]["$dataType"]>
: never
: Schema[K]["$dataType"]
} & InferOptionalFields<Schema>
>
/**
* Helper to infer the schema type of a DmlEntity

View File

@@ -12,6 +12,7 @@ describe("Array property", () => {
name: "array",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})

View File

@@ -1,6 +1,6 @@
import { PropertyMetadata } from "@medusajs/types"
import { expectTypeOf } from "expect-type"
import { BaseProperty } from "../properties/base"
import { PropertyMetadata } from "@medusajs/types"
import { TextProperty } from "../properties/text"
describe("Base property", () => {
@@ -20,6 +20,7 @@ describe("Base property", () => {
name: "text",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
@@ -38,6 +39,7 @@ describe("Base property", () => {
},
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
@@ -59,6 +61,7 @@ describe("Base property", () => {
name: "text",
},
nullable: true,
optional: false,
indexes: [],
relationships: [],
})
@@ -81,6 +84,98 @@ describe("Base property", () => {
},
defaultValue: "foo",
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
})
test("apply optional modifier", () => {
class StringProperty extends BaseProperty<string> {
protected dataType: PropertyMetadata["dataType"] = {
name: "text",
}
}
const property = new StringProperty().optional()
expectTypeOf(property["$dataType"]).toEqualTypeOf<string | undefined>()
expect(property.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "text",
},
nullable: false,
optional: true,
indexes: [],
relationships: [],
})
})
test("apply optional and nullable modifier", () => {
class StringProperty extends BaseProperty<string> {
protected dataType: PropertyMetadata["dataType"] = {
name: "text",
}
}
const property = new StringProperty().optional().nullable()
expectTypeOf(property["$dataType"]).toEqualTypeOf<
string | undefined | null
>()
expect(property.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "text",
},
nullable: true,
optional: true,
indexes: [],
relationships: [],
})
})
test("apply nullable and optional modifier", () => {
class StringProperty extends BaseProperty<string> {
protected dataType: PropertyMetadata["dataType"] = {
name: "text",
}
}
const property = new StringProperty().nullable().optional()
expectTypeOf(property["$dataType"]).toEqualTypeOf<
string | null | undefined
>()
expect(property.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "text",
},
nullable: true,
optional: true,
indexes: [],
relationships: [],
})
})
test("define default value as a callback", () => {
class StringProperty extends BaseProperty<string> {
protected dataType: PropertyMetadata["dataType"] = {
name: "text",
}
}
const property = new StringProperty().default(() => "22")
expectTypeOf(property["$dataType"]).toEqualTypeOf<string>()
expect(property.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "text",
},
defaultValue: expect.any(Function),
nullable: false,
optional: false,
indexes: [],
relationships: [],
})

View File

@@ -12,6 +12,7 @@ describe("Big Number property", () => {
name: "bigNumber",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})

View File

@@ -12,6 +12,7 @@ describe("Boolean property", () => {
name: "boolean",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})

View File

@@ -12,6 +12,7 @@ describe("DateTime property", () => {
name: "dateTime",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})

View File

@@ -8,6 +8,7 @@ import {
toMikroOrmEntities,
toMikroORMEntity,
} from "../helpers/create-mikro-orm-entity"
import { InferTypeOf } from "@medusajs/types"
describe("Entity builder", () => {
beforeEach(() => {
@@ -1447,6 +1448,130 @@ describe("Entity builder", () => {
},
})
})
test("define a property with default runtime value", () => {
const user = model.define("user", {
id: model.number(),
username: model.text().default((schema) => {
const { email } = schema as InferTypeOf<typeof user>
return email.replace(/\@.*/, "")
}),
email: model.text(),
spend_limit: model.bigNumber().default(500.4),
})
const User = toMikroORMEntity(user)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
email: string
deleted_at: Date | null
}>()
const metaData = MetadataStorage.getMetadataFromDecorator(User)
expect(metaData.className).toEqual("User")
expect(metaData.path).toEqual("User")
expect(metaData.filters).toEqual({
softDeletable: {
name: "softDeletable",
cond: expect.any(Function),
default: true,
args: false,
},
})
expect(metaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
fieldName: "id",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
default: expect.any(Function),
columnType: "text",
name: "username",
fieldName: "username",
nullable: false,
getter: false,
setter: false,
},
email: {
reference: "scalar",
type: "string",
columnType: "text",
name: "email",
fieldName: "email",
nullable: false,
getter: false,
setter: false,
},
spend_limit: {
columnType: "numeric",
default: 500.4,
getter: true,
name: "spend_limit",
fieldName: "spend_limit",
nullable: false,
reference: "scalar",
setter: true,
trackChanges: false,
type: "any",
},
raw_spend_limit: {
columnType: "jsonb",
getter: false,
name: "raw_spend_limit",
fieldName: "raw_spend_limit",
nullable: false,
reference: "scalar",
setter: false,
type: "any",
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
fieldName: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
fieldName: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
fieldName: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
})
})
describe("Entity builder | id", () => {

View File

@@ -17,6 +17,7 @@ describe("Enum property", () => {
},
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
@@ -42,6 +43,7 @@ describe("Enum property", () => {
},
},
nullable: true,
optional: false,
indexes: [],
relationships: [],
})
@@ -66,6 +68,7 @@ describe("Enum property", () => {
},
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
@@ -90,6 +93,7 @@ describe("Enum property", () => {
},
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})

View File

@@ -13,6 +13,7 @@ describe("Id property", () => {
options: {},
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
@@ -29,6 +30,7 @@ describe("Id property", () => {
options: {},
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
primaryKey: true,

View File

@@ -12,6 +12,7 @@ describe("JSON property", () => {
name: "json",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
@@ -30,6 +31,7 @@ describe("JSON property", () => {
a: 1,
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})

View File

@@ -13,6 +13,7 @@ describe("Number property", () => {
options: {},
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})

View File

@@ -13,6 +13,7 @@ describe("Text property", () => {
options: { searchable: false },
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
@@ -29,6 +30,7 @@ describe("Text property", () => {
options: { searchable: false },
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
primaryKey: true,

View File

@@ -1,5 +1,6 @@
import { PropertyMetadata, PropertyType } from "@medusajs/types"
import { NullableModifier } from "./nullable"
import { OptionalModifier } from "./optional"
/**
* The BaseProperty class offers shared affordances to define
@@ -15,7 +16,7 @@ export abstract class BaseProperty<T> implements PropertyType<T> {
/**
* Default value for the property
*/
#defaultValue?: T
#defaultValue?: T | ((schema: any) => T)
/**
* The runtime dataType for the schema. It is not the same as
@@ -48,6 +49,25 @@ export abstract class BaseProperty<T> implements PropertyType<T> {
return new NullableModifier<T, this>(this)
}
/**
* This method indicates that a property's value can be `optional`.
*
* @example
* import { model } from "@medusajs/framework/utils"
*
* const MyCustom = model.define("my_custom", {
* price: model.bigNumber().optional(),
* // ...
* })
*
* export default MyCustom
*
* @customNamespace Property Configuration Methods
*/
optional() {
return new OptionalModifier<T, this>(this)
}
/**
* This method defines an index on a property.
*
@@ -119,7 +139,7 @@ export abstract class BaseProperty<T> implements PropertyType<T> {
*
* @customNamespace Property Configuration Methods
*/
default(value: T) {
default(value: T | ((schema: any) => T)) {
this.#defaultValue = value
return this
}
@@ -132,6 +152,7 @@ export abstract class BaseProperty<T> implements PropertyType<T> {
fieldName,
dataType: this.dataType,
nullable: false,
optional: false,
defaultValue: this.#defaultValue,
indexes: this.#indexes,
relationships: this.#relationships,

View File

@@ -7,6 +7,7 @@ export * from "./enum"
export * from "./id"
export * from "./json"
export * from "./nullable"
export * from "./optional"
export * from "./number"
export * from "./primary-key"
export * from "./text"

View File

@@ -1,10 +1,11 @@
import { PropertyType } from "@medusajs/types"
import { OptionalModifier } from "./optional"
const IsNullableModifier = Symbol.for("isNullableModifier")
/**
* Nullable modifier marks a schema node as nullable
*/
export class NullableModifier<T, Schema extends PropertyType<T>>
export class NullableModifier<T, Schema extends PropertyType<NoInfer<T>>>
implements PropertyType<T | null>
{
[IsNullableModifier]: true = true
@@ -12,6 +13,7 @@ export class NullableModifier<T, Schema extends PropertyType<T>>
static isNullableModifier(obj: any): obj is NullableModifier<any, any> {
return !!obj?.[IsNullableModifier]
}
/**
* A type-only property to infer the JavScript data-type
* of the schema property
@@ -28,6 +30,25 @@ export class NullableModifier<T, Schema extends PropertyType<T>>
this.#schema = schema
}
/**
* This method indicates that a property's value can be `optional`.
*
* @example
* import { model } from "@medusajs/framework/utils"
*
* const MyCustom = model.define("my_custom", {
* price: model.bigNumber().optional(),
* // ...
* })
*
* export default MyCustom
*
* @customNamespace Property Configuration Methods
*/
optional() {
return new OptionalModifier<T | null, this>(this)
}
/**
* Returns the serialized metadata
*/

View File

@@ -0,0 +1,62 @@
import { PropertyType } from "@medusajs/types"
import { NullableModifier } from "./nullable"
const IsOptionalModifier = Symbol.for("IsOptionalModifier")
/**
* Nullable modifier marks a schema node as optional and
* allows for default values
*/
export class OptionalModifier<T, Schema extends PropertyType<T>>
implements PropertyType<T | undefined>
{
[IsOptionalModifier]: true = true
static isOptionalModifier(obj: any): obj is OptionalModifier<any, any> {
return !!obj?.[IsOptionalModifier]
}
/**
* A type-only property to infer the JavScript data-type
* of the schema property
*/
declare $dataType: T | undefined
/**
* The parent schema on which the nullable modifier is
* applied
*/
#schema: Schema
constructor(schema: Schema) {
this.#schema = schema
}
/**
* This method indicates that a property's value can be `null`.
*
* @example
* import { model } from "@medusajs/framework/utils"
*
* const MyCustom = model.define("my_custom", {
* price: model.bigNumber().optional().nullable(),
* // ...
* })
*
* export default MyCustom
*
* @customNamespace Property Configuration Methods
*/
nullable() {
return new NullableModifier<T | undefined, this>(this)
}
/**
* Returns the serialized metadata
*/
parse(fieldName: string) {
const schema = this.#schema.parse(fieldName)
schema.optional = true
return schema
}
}