chore: various DML improvements (#7833)

* chore: various DML improvements

* Check is something through static utils

* Check is something through static utils

* allow to define a schema with table name

* restrict searchable to text only

* rm searchable modifier

* extract constructor logic into separate function
This commit is contained in:
Adrien de Peretti
2024-06-25 18:00:39 +02:00
committed by GitHub
parent 4a631e789d
commit 39038ddb0a
18 changed files with 563 additions and 38 deletions
@@ -1,6 +1,7 @@
import { expectTypeOf } from "expect-type"
import { BaseProperty } from "../properties/base"
import { PropertyMetadata } from "@medusajs/types"
import { TextProperty } from "../properties/text"
describe("Base property", () => {
test("create a property type from base property", () => {
@@ -24,6 +25,25 @@ describe("Base property", () => {
})
})
test("apply searchable modifier", () => {
const property = new TextProperty().searchable()
expectTypeOf(property["$dataType"]).toEqualTypeOf<string>()
expect(property.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "text",
options: {
primaryKey: false,
searchable: true,
},
},
nullable: false,
indexes: [],
relationships: [],
})
})
test("apply nullable modifier", () => {
class StringProperty extends BaseProperty<string> {
protected dataType: PropertyMetadata["dataType"] = {
@@ -37,6 +37,9 @@ describe("Entity builder", () => {
spend_limit: model.bigNumber(),
})
expect(user.name).toEqual("user")
expect(user.parse().tableName).toEqual("user")
const User = toMikroORMEntity(user)
expectTypeOf(new User()).toMatchTypeOf<{
@@ -145,6 +148,250 @@ describe("Entity builder", () => {
})
})
test("define an entity with a table name and a name", () => {
const user = model.define(
{ name: "user", tableName: "user_table" },
{
id: model.number(),
username: model.text(),
email: model.text(),
spend_limit: model.bigNumber(),
}
)
expect(user.name).toEqual("user")
expect(user.parse().tableName).toEqual("user_table")
const User = toMikroORMEntity(user)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
email: string
spend_limit: number
raw_spend_limit: Record<string, unknown>
created_at: Date
updated_at: Date
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",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
columnType: "text",
name: "username",
nullable: false,
getter: false,
setter: false,
},
email: {
reference: "scalar",
type: "string",
columnType: "text",
name: "email",
nullable: false,
getter: false,
setter: false,
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
spend_limit: {
columnType: "numeric",
getter: true,
name: "spend_limit",
nullable: false,
reference: "scalar",
setter: true,
trackChanges: false,
type: "any",
},
raw_spend_limit: {
columnType: "jsonb",
getter: false,
name: "raw_spend_limit",
nullable: false,
reference: "scalar",
setter: false,
type: "any",
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "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",
nullable: true,
getter: false,
setter: false,
},
})
})
test("define an entity with a table name only", () => {
const user = model.define(
{ tableName: "user_role" },
{
id: model.number(),
username: model.text(),
email: model.text(),
spend_limit: model.bigNumber(),
}
)
expect(user.name).toEqual("userRole")
expect(user.parse().tableName).toEqual("user_role")
const User = toMikroORMEntity(user)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
email: string
spend_limit: number
raw_spend_limit: Record<string, unknown>
created_at: Date
updated_at: Date
deleted_at: Date | null
}>()
const metaData = MetadataStorage.getMetadataFromDecorator(User)
expect(metaData.className).toEqual("UserRole")
expect(metaData.path).toEqual("UserRole")
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",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
columnType: "text",
name: "username",
nullable: false,
getter: false,
setter: false,
},
email: {
reference: "scalar",
type: "string",
columnType: "text",
name: "email",
nullable: false,
getter: false,
setter: false,
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
spend_limit: {
columnType: "numeric",
getter: true,
name: "spend_limit",
nullable: false,
reference: "scalar",
setter: true,
trackChanges: false,
type: "any",
},
raw_spend_limit: {
columnType: "jsonb",
getter: false,
name: "raw_spend_limit",
nullable: false,
reference: "scalar",
setter: false,
type: "any",
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "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",
nullable: true,
getter: false,
setter: false,
},
})
})
test("define a property with default value", () => {
const user = model.define("user", {
id: model.number(),
@@ -258,6 +505,119 @@ describe("Entity builder", () => {
})
})
test("should mark a property as searchable", () => {
const user = model.define("user", {
id: model.number(),
username: model.text().searchable(),
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",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
columnType: "text",
name: "username",
nullable: false,
getter: false,
setter: false,
searchable: true,
},
email: {
reference: "scalar",
type: "string",
columnType: "text",
name: "email",
nullable: false,
getter: false,
setter: false,
},
spend_limit: {
columnType: "numeric",
default: 500.4,
getter: true,
name: "spend_limit",
nullable: false,
reference: "scalar",
setter: true,
trackChanges: false,
type: "any",
},
raw_spend_limit: {
columnType: "jsonb",
getter: false,
name: "raw_spend_limit",
nullable: false,
reference: "scalar",
setter: false,
type: "any",
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "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",
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",
nullable: true,
getter: false,
setter: false,
},
})
})
test("mark property nullable", () => {
const user = model.define("user", {
id: model.number(),
@@ -2472,7 +2832,7 @@ describe("Entity builder", () => {
email: model.hasOne(() => email),
})
const [User, Email] = [user, email].map(toMikroORMEntity)
const [User, Email] = toMikroOrmEntities([user, email])
expectTypeOf(new User()).toMatchTypeOf<{
id: number
@@ -20,4 +20,19 @@ describe("HasMany relationship", () => {
entity: entityRef,
})
})
test("should identify has many relationship", () => {
const user = {
username: new TextProperty(),
}
const entityRef = () => user
let relationship = new HasMany(entityRef, {})
expect(HasMany.isHasMany(relationship)).toEqual(true)
relationship = {} as any
expect(HasMany.isHasMany(relationship)).toEqual(false)
})
})
@@ -40,4 +40,19 @@ describe("HasOne relationship", () => {
entity: entityRef,
})
})
test("should identify has one relationship", () => {
const user = {
username: new TextProperty(),
}
const entityRef = () => user
let relationship = new HasOne(entityRef, {})
expect(HasOne.isHasOne(relationship)).toEqual(true)
relationship = {} as any
expect(HasOne.isHasOne(relationship)).toEqual(false)
})
})
@@ -20,4 +20,19 @@ describe("ManyToMany relationship", () => {
entity: entityRef,
})
})
test("should identify many to many relationship", () => {
const user = {
username: new TextProperty(),
}
const entityRef = () => user
let relationship = new ManyToMany(entityRef, {})
expect(ManyToMany.isManyToMany(relationship)).toEqual(true)
relationship = {} as any
expect(ManyToMany.isManyToMany(relationship)).toEqual(false)
})
})
@@ -10,7 +10,7 @@ describe("Text property", () => {
fieldName: "username",
dataType: {
name: "text",
options: { primaryKey: false },
options: { primaryKey: false, searchable: false },
},
nullable: false,
indexes: [],
@@ -29,6 +29,8 @@ export type DMLSchema = Record<
PropertyType<any> | RelationshipType<any>
>
type DefineOptions = string | { name?: string; tableName: string }
/**
* Entity builder exposes the API to create an entity and define its
* schema using the shorthand methods.
@@ -52,10 +54,13 @@ export class EntityBuilder {
* Define an entity or a model. The name should be unique across
* all the entities.
*/
define<Schema extends DMLSchema>(name: string, schema: Schema) {
define<Schema extends DMLSchema>(
nameOrConfig: DefineOptions,
schema: Schema
) {
this.#disallowImplicitProperties(schema)
return new DmlEntity(name, {
return new DmlEntity(nameOrConfig, {
...schema,
...createBigNumberProperties(schema),
...createDefaultProperties(),
+47 -7
View File
@@ -8,6 +8,40 @@ import {
} from "@medusajs/types"
import { DMLSchema } from "./entity-builder"
import { BelongsTo } from "./relations/belongs-to"
import { isObject, isString, toCamelCase } from "../common"
type Config = string | { name?: string; tableName: string }
function extractNameAndTableName(nameOrConfig: Config) {
const result = {
name: "",
tableName: "",
}
if (isString(nameOrConfig)) {
const [schema, ...rest] = nameOrConfig.split(".")
const name = rest.length ? rest.join(".") : schema
result.name = toCamelCase(name)
result.tableName = nameOrConfig
}
if (isObject(nameOrConfig)) {
if (!nameOrConfig.tableName) {
throw new Error(
`Missing "tableName" property in the config object for "${nameOrConfig.name}" entity`
)
}
const potentialName = nameOrConfig.name ?? nameOrConfig.tableName
const [schema, ...rest] = potentialName.split(".")
const name = rest.length ? rest.join(".") : schema
result.name = toCamelCase(name)
result.tableName = nameOrConfig.tableName
}
return result
}
/**
* Dml entity is a representation of a DML model with a unique
@@ -16,8 +50,16 @@ import { BelongsTo } from "./relations/belongs-to"
export class DmlEntity<Schema extends DMLSchema> implements IDmlEntity<Schema> {
[IsDmlEntity]: true = true
name: string
readonly #tableName: string
#cascades: EntityCascades<string[]> = {}
constructor(public name: string, public schema: Schema) {}
constructor(nameOrConfig: Config, public schema: Schema) {
const { name, tableName } = extractNameAndTableName(nameOrConfig)
this.name = name
this.#tableName = tableName
}
/**
* A static method to check if an entity is an instance of DmlEntity.
@@ -27,11 +69,7 @@ export class DmlEntity<Schema extends DMLSchema> implements IDmlEntity<Schema> {
* @param entity
*/
static isDmlEntity(entity: unknown): entity is DmlEntity<any> {
return (
!!entity &&
(entity instanceof DmlEntity ||
(typeof entity === "object" && entity[IsDmlEntity] === true))
)
return !!entity?.[IsDmlEntity]
}
/**
@@ -39,11 +77,13 @@ export class DmlEntity<Schema extends DMLSchema> implements IDmlEntity<Schema> {
*/
parse(): {
name: string
tableName: string
schema: PropertyType<any> | RelationshipType<any>
cascades: EntityCascades<string[]>
} {
return {
name: this.name,
tableName: this.#tableName,
schema: this.schema as unknown as
| PropertyType<any>
| RelationshipType<any>,
@@ -63,7 +103,7 @@ export class DmlEntity<Schema extends DMLSchema> implements IDmlEntity<Schema> {
>
) {
const childToParentCascades = options.delete?.filter((relationship) => {
return this.schema[relationship] instanceof BelongsTo
return BelongsTo.isBelongsTo(this.schema[relationship])
})
if (childToParentCascades?.length) {
@@ -33,6 +33,7 @@ import { upperCaseFirst } from "../../common/upper-case-first"
import {
MikroOrmBigNumberProperty,
mikroOrmSoftDeletableFilterOptions,
Searchable,
} from "../../dal"
import { DmlEntity } from "../entity"
import { HasMany } from "../relations/has-many"
@@ -118,14 +119,16 @@ export function createMikrORMEntity() {
* Parses entity name and returns model and table name from
* it
*/
function parseEntityName(entityName: string) {
function parseEntityName(entity: DmlEntity<any>) {
const parsedEntity = entity.parse()
/**
* Table name is going to be the snake case version of the entity name.
* Here we should preserve PG schema (if defined).
*
* For example: "platform.user" should stay as "platform.user"
*/
const tableName = camelToSnakeCase(entityName)
const tableName = camelToSnakeCase(parsedEntity.tableName)
/**
* Entity name is going to be the camelCase version of the
@@ -134,9 +137,7 @@ export function createMikrORMEntity() {
const [pgSchema, ...rest] = tableName.split(".")
return {
tableName,
modelName: upperCaseFirst(
toCamelCase(rest.length ? rest.join("_") : pgSchema)
),
modelName: upperCaseFirst(toCamelCase(parsedEntity.name)),
pgSchema: rest.length ? pgSchema : undefined,
}
}
@@ -308,6 +309,20 @@ export function createMikrORMEntity() {
})
}
/**
* Apply the searchable decorator to the property marked as searchable to enable the free text search
*/
function applySearchable(
MikroORMEntity: EntityConstructor<any>,
field: PropertyMetadata
) {
if (!field.dataType.options?.searchable) {
return
}
Searchable()(MikroORMEntity.prototype, field.fieldName)
}
/**
* Defines has one relationship on the Mikro ORM entity.
*/
@@ -412,8 +427,8 @@ export function createMikrORMEntity() {
* Otherside is a has many. Hence we should defined a ManyToOne
*/
if (
otherSideRelation instanceof HasMany ||
otherSideRelation instanceof DmlManyToMany
HasMany.isHasMany(otherSideRelation) ||
DmlManyToMany.isManyToMany(otherSideRelation)
) {
const foreignKeyName = camelToSnakeCase(`${relationship.name}Id`)
@@ -426,7 +441,7 @@ export function createMikrORMEntity() {
onDelete: shouldCascade ? "cascade" : undefined,
})(MikroORMEntity.prototype, camelToSnakeCase(`${relationship.name}Id`))
if (otherSideRelation instanceof DmlManyToMany) {
if (DmlManyToMany.isManyToMany(otherSideRelation)) {
Property({
type: relatedModelName,
persist: false,
@@ -448,7 +463,7 @@ export function createMikrORMEntity() {
/**
* Otherside is a has one. Hence we should defined a OneToOne
*/
if (otherSideRelation instanceof HasOne) {
if (HasOne.isHasOne(otherSideRelation)) {
const foreignKeyName = camelToSnakeCase(`${relationship.name}Id`)
OneToOne({
@@ -516,7 +531,7 @@ export function createMikrORMEntity() {
)
}
if (otherSideRelation instanceof DmlManyToMany === false) {
if (!DmlManyToMany.isManyToMany(otherSideRelation)) {
throw new Error(
`Invalid relationship reference for "${mappedBy}" on "${relatedModelName}" entity. Make sure to define a manyToMany relationship`
)
@@ -554,13 +569,13 @@ export function createMikrORMEntity() {
}
const pivotEntity = relationship.options.pivotEntity()
if (!(pivotEntity instanceof DmlEntity)) {
if (!DmlEntity.isDmlEntity(pivotEntity)) {
throw new Error(
`Invalid pivotEntity reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to return a DML entity from the pivotEntity callback`
)
}
pivotEntityName = parseEntityName(pivotEntity.parse().name).modelName
pivotEntityName = parseEntityName(pivotEntity).modelName
}
if (!pivotEntityName) {
@@ -630,15 +645,13 @@ export function createMikrORMEntity() {
/**
* Ensure the return value is a DML entity instance
*/
if (!(relatedEntity instanceof DmlEntity)) {
if (!DmlEntity.isDmlEntity(relatedEntity)) {
throw new Error(
`Invalid relationship reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to return a DML entity from the relationship callback`
)
}
const { modelName, tableName, pgSchema } = parseEntityName(
relatedEntity.parse().name
)
const { modelName, tableName, pgSchema } = parseEntityName(relatedEntity)
const relatedEntityInfo = {
relatedModelName: modelName,
relatedTableName: tableName,
@@ -691,8 +704,8 @@ export function createMikrORMEntity() {
return function createEntity<T extends DmlEntity<any>>(entity: T): Infer<T> {
class MikroORMEntity {}
const { name, schema, cascades } = entity.parse()
const { modelName, tableName } = parseEntityName(name)
const { schema, cascades } = entity.parse()
const { modelName, tableName } = parseEntityName(entity)
/**
* Assigning name to the class constructor, so that it matches
@@ -713,6 +726,7 @@ export function createMikrORMEntity() {
if ("fieldName" in field) {
defineProperty(MikroORMEntity, field)
applyIndexes(MikroORMEntity, tableName, field)
applySearchable(MikroORMEntity, field)
} else {
defineRelationship(MikroORMEntity, field, cascades)
}
@@ -734,16 +748,14 @@ export function createMikrORMEntity() {
*/
export const toMikroORMEntity = <T>(
entity: T
): T extends DmlEntity<infer Schema> ? EntityConstructor<Schema> : T => {
): T extends DmlEntity<infer Schema> ? Infer<T> : T => {
let mikroOrmEntity: T | EntityConstructor<any> = entity
if (DmlEntity.isDmlEntity(entity)) {
mikroOrmEntity = createMikrORMEntity()(entity)
}
return mikroOrmEntity as T extends DmlEntity<infer Schema>
? EntityConstructor<Schema>
: T
return mikroOrmEntity as T extends DmlEntity<infer Schema> ? Infer<T> : T
}
/**
@@ -761,6 +773,6 @@ export const toMikroOrmEntities = function <T extends any[]>(entities: T) {
return entity
}) as {
[K in keyof T]: T[K] extends DmlEntity<any> ? EntityConstructor<T[K]> : T[K]
[K in keyof T]: T[K] extends DmlEntity<any> ? Infer<T[K]> : T[K]
}
}
@@ -32,8 +32,8 @@ export function createBigNumberProperties<Schema extends DMLSchema>(
for (const [key, property] of Object.entries(schema)) {
if (
property instanceof BigNumberProperty ||
property instanceof NullableModifier
BigNumberProperty.isBigNumberProperty(property) ||
NullableModifier.isNullableModifier(property)
) {
const parsed = property.parse(key)
@@ -8,4 +8,8 @@ export class BigNumberProperty extends BaseProperty<number> {
protected dataType = {
name: "bigNumber",
} as const
static isBigNumberProperty(obj: any): obj is BigNumberProperty {
return obj?.dataType?.name === "bigNumber"
}
}
@@ -1,11 +1,17 @@
import { PropertyType } from "@medusajs/types"
const IsNullableModifier = Symbol.for("isNullableModifier")
/**
* Nullable modifier marks a schema node as nullable
*/
export class NullableModifier<T, Schema extends PropertyType<T>>
implements PropertyType<T | null>
{
[IsNullableModifier]: true = true
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
@@ -8,6 +8,7 @@ export class TextProperty extends BaseProperty<string> {
name: "text"
options: {
primaryKey: boolean
searchable: boolean
}
}
@@ -17,12 +18,18 @@ export class TextProperty extends BaseProperty<string> {
return this
}
constructor(options?: { primaryKey?: boolean }) {
searchable() {
this.dataType.options.searchable = true
return this
}
constructor(options?: { primaryKey?: boolean; searchable?: boolean }) {
super()
this.dataType = {
name: "text",
options: { primaryKey: false, ...options },
options: { primaryKey: false, searchable: false, ...options },
}
}
}
@@ -4,6 +4,10 @@ import { NullableModifier } from "./nullable"
export class BelongsTo<T> extends BaseRelationship<T> {
type = "belongsTo" as const
static isBelongsTo<T>(relationship: any): relationship is BelongsTo<T> {
return relationship?.type === "belongsTo"
}
/**
* Apply nullable modifier on the schema
*/
@@ -12,4 +12,8 @@ import { BaseRelationship } from "./base"
*/
export class HasMany<T> extends BaseRelationship<T> {
type = "hasMany" as const
static isHasMany<T>(relationship: any): relationship is HasMany<T> {
return relationship?.type === "hasMany"
}
}
@@ -14,6 +14,10 @@ import { NullableModifier } from "./nullable"
export class HasOne<T> extends BaseRelationship<T> {
type = "hasOne" as const
static isHasOne<T>(relationship: any): relationship is HasOne<T> {
return relationship?.type === "hasOne"
}
/**
* Apply nullable modifier on the schema
*/
@@ -13,4 +13,8 @@ import { BaseRelationship } from "./base"
*/
export class ManyToMany<T> extends BaseRelationship<T> {
type = "manyToMany" as const
static isManyToMany<T>(relationship: any): relationship is ManyToMany<T> {
return relationship?.type === "manyToMany"
}
}
@@ -1,11 +1,21 @@
import { RelationshipType } from "@medusajs/types"
const IsNullableModifier = Symbol.for("isNullableModifier")
/**
* Nullable modifier marks a schema node as nullable
*/
export class NullableModifier<T, Relation extends RelationshipType<T>>
implements RelationshipType<T | null>
{
[IsNullableModifier]: true = true
static isNullableModifier<T>(
modifier: any
): modifier is NullableModifier<T, any> {
return !!modifier?.[IsNullableModifier]
}
declare type: RelationshipType<T>["type"]
/**