Code cleanup and add support for default values and nullable relationships (#7687)

This commit is contained in:
Harminder Virk
2024-06-12 14:39:03 +05:30
committed by GitHub
parent ee6bdd0ab0
commit 6d43daa930
30 changed files with 291 additions and 131 deletions

View File

@@ -21,6 +21,35 @@ describe("Base relationship", () => {
expect(relationship.parse("user")).toEqual({
name: "user",
type: "hasOne",
nullable: false,
entity: entityRef,
options: {
foreignKey: "user_id",
},
})
})
test("mark relationship as nullable", () => {
class HasOne<T> extends BaseRelationship<T> {
protected relationshipType: "hasOne" | "hasMany" | "manyToMany" = "hasOne"
}
const user = {
username: new TextSchema(),
}
const entityRef = () => user
const relationship = new HasOne(entityRef, {
foreignKey: "user_id",
}).nullable()
expectTypeOf(relationship["$dataType"]).toEqualTypeOf<
(() => typeof user) | null
>()
expect(relationship.parse("user")).toEqual({
name: "user",
type: "hasOne",
nullable: true,
entity: entityRef,
options: {
foreignKey: "user_id",

View File

@@ -6,7 +6,7 @@ describe("Base schema", () => {
test("create a schema type from base schema", () => {
class StringSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "string",
name: "text",
}
}
@@ -16,10 +16,9 @@ describe("Base schema", () => {
expect(schema.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "string",
name: "text",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
@@ -28,7 +27,7 @@ describe("Base schema", () => {
test("apply nullable modifier", () => {
class StringSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "string",
name: "text",
}
}
@@ -38,54 +37,31 @@ describe("Base schema", () => {
expect(schema.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "string",
name: "text",
},
nullable: true,
optional: false,
indexes: [],
relationships: [],
})
})
test("apply optional modifier", () => {
test("define default value", () => {
class StringSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "string",
name: "text",
}
}
const schema = new StringSchema().optional()
const schema = new StringSchema().default("foo")
expectTypeOf(schema["$dataType"]).toEqualTypeOf<string | undefined>()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<string>()
expect(schema.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "string",
name: "text",
},
defaultValue: "foo",
nullable: false,
optional: true,
indexes: [],
relationships: [],
})
})
test("apply optional + nullable modifier", () => {
class StringSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "string",
}
}
const schema = new StringSchema().optional().nullable()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<string | undefined | null>()
expect(schema.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "string",
},
nullable: true,
optional: true,
indexes: [],
relationships: [],
})

View File

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

View File

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

View File

@@ -122,6 +122,56 @@ describe("Entity builder", () => {
])
})
test("define an entity with default value", () => {
const model = new EntityBuilder()
const user = model.define("user", {
id: model.number(),
username: model.text().default("foo"),
email: model.text(),
})
const User = createMikrORMEntity(user)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
email: string
}>()
const metaData = MetadataStorage.getMetadataFromDecorator(User)
expect(metaData.className).toEqual("User")
expect(metaData.path).toEqual("User")
expect(metaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
default: "foo",
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,
},
})
})
test("define hasMany relationship", () => {
const model = new EntityBuilder()
const email = model.define("email", {

View File

@@ -17,19 +17,16 @@ describe("Enum schema", () => {
},
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
})
test("apply nullable and optional modifiers", () => {
const schema = new EnumSchema(["admin", "moderator", "editor"])
.nullable()
.optional()
test("apply nullable modifier", () => {
const schema = new EnumSchema(["admin", "moderator", "editor"]).nullable()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<
"admin" | "moderator" | "editor" | null | undefined
"admin" | "moderator" | "editor" | null
>()
expect(schema.parse("role")).toEqual({
@@ -41,7 +38,6 @@ describe("Enum schema", () => {
},
},
nullable: true,
optional: true,
indexes: [],
relationships: [],
})

View File

@@ -15,6 +15,7 @@ describe("HasMany relationship", () => {
expect(relationship.parse("user")).toEqual({
name: "user",
type: "hasMany",
nullable: false,
entity: entityRef,
options: {},
})

View File

@@ -15,6 +15,7 @@ describe("HasOne relationship", () => {
expect(relationship.parse("user")).toEqual({
name: "user",
type: "hasOne",
nullable: false,
entity: entityRef,
options: {},
})

View File

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

View File

@@ -15,6 +15,7 @@ describe("ManyToMany relationship", () => {
expect(relationship.parse("user")).toEqual({
name: "user",
type: "manyToMany",
nullable: false,
entity: entityRef,
options: {},
})

View File

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

View File

@@ -9,10 +9,9 @@ describe("String schema", () => {
expect(schema.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "string",
name: "text",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})

View File

@@ -1,5 +1,9 @@
import { RelationshipType, SchemaType } from "./types"
/**
* Dml entity is a representation of a DML model with a unique
* name, its schema and relationships.
*/
export class DmlEntity<
Schema extends Record<string, SchemaType<any> | RelationshipType<any>>
> {

View File

@@ -1,5 +1,6 @@
import { DmlEntity } from "./entity"
import { TextSchema } from "./schema/text"
import { EnumSchema } from "./schema/enum"
import { JSONSchema } from "./schema/json"
import { HasOne } from "./relations/has_one"
import { HasMany } from "./relations/has_many"
@@ -8,52 +9,114 @@ import { BooleanSchema } from "./schema/boolean"
import { DateTimeSchema } from "./schema/date_time"
import { ManyToMany } from "./relations/many_to_many"
import { RelationshipType, SchemaType } from "./types"
import { EnumSchema } from "./schema/enum"
import { HasOneThroughMany } from "./relations/has_one_through_many"
/**
* Entity builder exposes the API to create an entity and define its
* schema using the shorthand methods.
*/
export class EntityBuilder {
/**
* Define an entity or a model. The name should be unique across
* all the entities.
*/
define<
Schema extends Record<string, SchemaType<any> | RelationshipType<any>>
>(name: string, schema: Schema) {
return new DmlEntity(name, schema)
}
/**
* Define a text/string based column
*/
text() {
return new TextSchema()
}
/**
* Define a boolean column
*/
boolean() {
return new BooleanSchema()
}
/**
* Define a numeric/integer column
*/
number() {
return new NumberSchema()
}
/**
* Define a timestampz column
*/
dateTime() {
return new DateTimeSchema()
}
/**
* Define a JSON column to store data as a
* JSON string
*/
json() {
return new JSONSchema()
}
/**
* Define an enum column where only a pre-defined set
* of values are allowed.
*/
enum<const Values extends unknown>(values: Values[]) {
return new EnumSchema<Values>(values)
}
/**
* Has one relationship defines a relationship between two entities
* where the owner of the relationship has exactly one instance
* of the related entity.
*
* For example: A user "hasOne" profile
*
* You may use the "belongsTo" relationship to define the inverse
* of the "hasOne" relationship
*/
hasOne<T>(entityBuilder: T, options?: Record<string, any>) {
return new HasOne<T>(entityBuilder, options || {})
}
/**
* Has many relationship defines a relationship between two entities
* where the owner of the relationship has many instance of the
* related entity.
*
* For example:
*
* - A user "hasMany" books
* - A user "hasMany" addresses
*/
hasMany<T>(entityBuilder: T, options?: Record<string, any>) {
return new HasMany<T>(entityBuilder, options || {})
}
/**
* Define a hasOneThroughMany relationship between two entities.
* @todo Remove in favor of "belongTo"
*/
hasOneThroughMany<T>(entityBuilder: T, options?: Record<string, any>) {
return new HasOneThroughMany<T>(entityBuilder, options || {})
}
/**
* ManyToMany relationship defines a relationship between two entities
* where the owner of the relationship has many instance of the
* related entity via a pivot table.
*
* For example:
*
* - A user has many teams. But a team has many users as well. So this
* relationship requires a pivot table to establish a many to many
* relationship between two entities
*/
manyToMany<T>(entityBuilder: T, options?: Record<string, any>) {
return new ManyToMany<T>(entityBuilder, options || {})
}

View File

@@ -23,32 +23,36 @@ import type {
/**
* DML entity data types to PostgreSQL data types via
* Mikro ORM
* Mikro ORM.
*
* We remove "enum" type from here, because we use a dedicated
* mikro orm decorator for that
*/
const COLUMN_TYPES: {
[K in KnownDataTypes]: string
[K in Exclude<KnownDataTypes, "enum">]: string
} = {
boolean: "boolean",
dateTime: "timestamptz",
number: "integer",
string: "text",
text: "text",
json: "jsonb",
enum: "enum", // ignore for now
}
/**
* DML entity data types to Mikro ORM property
* types
* types.
*
* We remove "enum" type from here, because we use a dedicated
* mikro orm decorator for that
*/
const PROPERTY_TYPES: {
[K in KnownDataTypes]: string
[K in Exclude<KnownDataTypes, "enum">]: string
} = {
boolean: "boolean",
dateTime: "date",
number: "number",
string: "string",
text: "string",
json: "any",
enum: "enum", // ignore for now
}
/**
@@ -65,20 +69,23 @@ function defineProperty(
Enum({
items: () => field.dataType.options!.choices,
nullable: field.nullable,
default: field.defaultValue,
})(MikroORMEntity.prototype, field.fieldName)
return
}
/**
* Define rest of properties
*/
const columnType = COLUMN_TYPES[field.dataType.name]
const propertyType = PROPERTY_TYPES[field.dataType.name]
/**
* @todo: Add support for default value
*/
Property({ columnType, type: propertyType, nullable: field.nullable })(
MikroORMEntity.prototype,
field.fieldName
)
Property({
columnType,
type: propertyType,
nullable: field.nullable,
default: field.defaultValue,
})(MikroORMEntity.prototype, field.fieldName)
}
/**
@@ -89,7 +96,8 @@ function defineRelationship(
relationship: RelationshipMetadata
) {
/**
* Defining relationships
* We expect the relationship.entity to be a function that
* lazily returns the related entity
*/
const relatedEntity =
typeof relationship.entity === "function"
@@ -115,6 +123,10 @@ function defineRelationship(
)
}
/**
* Converting the related entity name (which should be in camelCase)
* to "PascalCase"
*/
const relatedModelName = upperCaseFirst(relatedEntity.name)
/**

View File

@@ -1,6 +1,8 @@
import { SchemaType } from "../types"
import { OptionalModifier } from "./optional"
import { MaybeFieldMetadata } from "../types"
/**
* Nullable modifier marks a schema node as nullable
*/
export class NullableModifier<T> {
/**
* A type-only property to infer the JavScript data-type
@@ -12,17 +14,12 @@ export class NullableModifier<T> {
* The parent schema on which the nullable modifier is
* applied
*/
#schema: SchemaType<T>
constructor(schema: SchemaType<T>) {
this.#schema = schema
#schema: {
parse(fieldName: string): MaybeFieldMetadata
}
/**
* Apply optional modifier on the schema
*/
optional() {
return new OptionalModifier<T | null>(this)
constructor(schema: { parse(fieldName: string): MaybeFieldMetadata }) {
this.#schema = schema
}
/**

View File

@@ -1,36 +0,0 @@
import { SchemaType } from "../types"
import { NullableModifier } from "./nullable"
export class OptionalModifier<T> {
/**
* 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: SchemaType<T>
constructor(schema: SchemaType<T>) {
this.#schema = schema
}
/**
* Apply nullable modifier on the schema
*/
nullable() {
return new NullableModifier<T | undefined>(this)
}
/**
* Returns the serialized metadata
*/
parse(fieldName: string) {
const schema = this.#schema.parse(fieldName)
schema.optional = true
return schema
}
}

View File

@@ -1,3 +1,4 @@
import { NullableModifier } from "../modifiers/nullable"
import { RelationshipMetadata, RelationshipType } from "../types"
/**
@@ -24,12 +25,20 @@ export abstract class BaseRelationship<T> implements RelationshipType<T> {
this.#options = options
}
/**
* Apply nullable modifier on the schema
*/
nullable() {
return new NullableModifier<T>(this)
}
/**
* Returns the parsed copy of the relationship
*/
parse(relationshipName: string): RelationshipMetadata {
return {
name: relationshipName,
nullable: false,
entity: this.#referencedEntity,
options: this.#options,
type: this.relationshipType,

View File

@@ -1,6 +1,16 @@
import { BaseRelationship } from "./base"
import { RelationshipMetadata } from "../types"
/**
* HasMany relationship defines a relationship between two entities
* where the owner of the relationship has many instance of the
* related entity.
*
* For example:
*
* - A user HasMany books
* - A user HasMany addresses
*/
export class HasMany<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "hasMany"
}

View File

@@ -1,6 +1,16 @@
import { BaseRelationship } from "./base"
import { RelationshipMetadata } from "../types"
/**
* HasOne relationship defines a relationship between two entities
* where the owner of the relationship has exactly one instance
* of the related entity.
*
* For example: A user HasOne profile
*
* You may use the "BelongsTo" relationship to define the inverse
* of the "HasOne" relationship
*/
export class HasOne<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "hasOne"
}

View File

@@ -1,6 +1,17 @@
import { BaseRelationship } from "./base"
import { RelationshipMetadata } from "../types"
/**
* ManyToMany relationship defines a relationship between two entities
* where the owner of the relationship has many instance of the
* related entity via a pivot table.
*
* For example:
*
* - A user has many teams. But a team has many users as well. So this
* relationship requires a pivot table to establish a many to many
* relationship between two entities
*/
export class ManyToMany<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "manyToMany"
}

View File

@@ -1,6 +1,5 @@
import { SchemaMetadata, SchemaType } from "../types"
import { NullableModifier } from "../modifiers/nullable"
import { OptionalModifier } from "../modifiers/optional"
/**
* The base schema class offers shared affordances to define
@@ -9,6 +8,7 @@ import { OptionalModifier } from "../modifiers/optional"
export abstract class BaseSchema<T> implements SchemaType<T> {
#indexes: SchemaMetadata["indexes"] = []
#relationships: SchemaMetadata["relationships"] = []
#defaultValue?: T
/**
* The runtime dataType for the schema. It is not the same as
@@ -30,10 +30,11 @@ export abstract class BaseSchema<T> implements SchemaType<T> {
}
/**
* Apply optional modifier on the schema
* Define default value for the property
*/
optional() {
return new OptionalModifier<T>(this)
default(value: T) {
this.#defaultValue = value
return this
}
/**
@@ -44,7 +45,7 @@ export abstract class BaseSchema<T> implements SchemaType<T> {
fieldName,
dataType: this.dataType,
nullable: false,
optional: false,
defaultValue: this.#defaultValue,
indexes: this.#indexes,
relationships: this.#relationships,
}

View File

@@ -1,6 +1,10 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
/**
* The BooleanSchema class is used to define a boolean
* property
*/
export class BooleanSchema extends BaseSchema<boolean> {
protected dataType: SchemaMetadata["dataType"] = {
name: "boolean",

View File

@@ -1,6 +1,10 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
/**
* The DateTimeSchema class is used to define a timestampz
* property
*/
export class DateTimeSchema extends BaseSchema<Date> {
protected dataType: SchemaMetadata["dataType"] = {
name: "dateTime",

View File

@@ -1,6 +1,10 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
/**
* The EnumSchema is used to define a property with pre-defined
* list of choices.
*/
export class EnumSchema<
const Values extends unknown
> extends BaseSchema<Values> {

View File

@@ -1,6 +1,10 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
/**
* The JSONSchema is used to define a property that stores
* data as a JSON string
*/
export class JSONSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "json",

View File

@@ -1,6 +1,10 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
/**
* The NumberSchema is used to define a numeric/integer
* property
*/
export class NumberSchema extends BaseSchema<number> {
protected dataType: SchemaMetadata["dataType"] = {
name: "number",

View File

@@ -1,8 +1,11 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
/**
* The NumberSchema is used to define a textual property
*/
export class TextSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "string",
name: "text",
}
}

View File

@@ -1,3 +0,0 @@
export const MIKRO_ORM_ENTITY_GENERATOR = Symbol.for(
"generate_mikro_orm_entity"
)

View File

@@ -4,7 +4,7 @@ import { DmlEntity } from "./entity"
* The supported data types
*/
export type KnownDataTypes =
| "string"
| "text"
| "boolean"
| "enum"
| "number"
@@ -12,23 +12,22 @@ export type KnownDataTypes =
| "json"
/**
* The meta-data returned by the relationship parse
* method
* Any field that contains "nullable" and "optional" properties
* in their metadata are qualified as maybe fields.
*
* This allows us to wrap them inside "NullableModifier" and
* "OptionalModifier" classes.
*/
export type RelationshipMetadata = {
name: string
type: "hasOne" | "hasMany" | "hasOneThroughMany" | "manyToMany"
entity: unknown
options: Record<string, any>
export type MaybeFieldMetadata = {
nullable: boolean
}
/**
* The meta-data returned by the schema parse method
*/
export type SchemaMetadata = {
nullable: boolean
optional: boolean
export type SchemaMetadata = MaybeFieldMetadata & {
fieldName: string
defaultValue?: any
dataType: {
name: KnownDataTypes
options?: Record<string, any>
@@ -50,6 +49,17 @@ export type SchemaType<T> = {
parse(fieldName: string): SchemaMetadata
}
/**
* The meta-data returned by the relationship parse
* method
*/
export type RelationshipMetadata = MaybeFieldMetadata & {
name: string
type: "hasOne" | "hasMany" | "hasOneThroughMany" | "manyToMany"
entity: unknown
options: Record<string, any>
}
/**
* Definition of a relationship type. It should have a parse
* method to get the metadata and a type-only property