Initial implementation of data types and base schema (#7644)

This commit is contained in:
Harminder Virk
2024-06-10 14:31:42 +05:30
committed by GitHub
parent 2597990363
commit 7f53fe06b6
31 changed files with 796 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import { expectTypeOf } from "expect-type"
import { TextSchema } from "../schema/text"
import { BaseRelationship } from "../relations/base"
describe("Base relationship", () => {
test("define a custom relationship", () => {
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",
})
expectTypeOf(relationship["$dataType"]).toEqualTypeOf<() => typeof user>()
expect(relationship.parse("user")).toEqual({
name: "user",
type: "hasOne",
entity: entityRef,
options: {
foreignKey: "user_id",
},
})
})
})

View File

@@ -0,0 +1,93 @@
import { expectTypeOf } from "expect-type"
import { BaseSchema } from "../schema/base"
import { SchemaMetadata } from "../types"
describe("Base schema", () => {
test("create a schema type from base schema", () => {
class StringSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "string",
}
}
const schema = new StringSchema()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<string>()
expect(schema.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "string",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
})
test("apply nullable modifier", () => {
class StringSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "string",
}
}
const schema = new StringSchema().nullable()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<string | null>()
expect(schema.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "string",
},
nullable: true,
optional: false,
indexes: [],
relationships: [],
})
})
test("apply optional modifier", () => {
class StringSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "string",
}
}
const schema = new StringSchema().optional()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<string | undefined>()
expect(schema.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "string",
},
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

@@ -0,0 +1,20 @@
import { expectTypeOf } from "expect-type"
import { BooleanSchema } from "../schema/boolean"
describe("Boolean schema", () => {
test("create boolean schema type", () => {
const schema = new BooleanSchema()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<boolean>()
expect(schema.parse("isAdmin")).toEqual({
fieldName: "isAdmin",
dataType: {
name: "boolean",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
})
})

View File

@@ -0,0 +1,20 @@
import { expectTypeOf } from "expect-type"
import { DateTimeSchema } from "../schema/date_time"
describe("DateTime schema", () => {
test("create datetime schema type", () => {
const schema = new DateTimeSchema()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<Date>()
expect(schema.parse("created_at")).toEqual({
fieldName: "created_at",
dataType: {
name: "dateTime",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
})
})

View File

@@ -0,0 +1,66 @@
import { expectTypeOf } from "expect-type"
import { Infer, MikroORMEntity } from "../types"
import { EntityBuilder } from "../entity_builder"
describe("Entity builder", () => {
test("define an entity", () => {
const model = new EntityBuilder()
const user = model.define("user", {
id: model.number(),
username: model.text(),
email: model.text(),
})
expectTypeOf<InstanceType<Infer<typeof user>>>().toMatchTypeOf<{
id: number
username: string
email: string
}>()
})
test("define an entity with relationships", () => {
const model = new EntityBuilder()
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
})
const user = model.define("user", {
id: model.number(),
username: model.text(),
emails: model.hasMany(() => email),
})
expectTypeOf<InstanceType<Infer<typeof user>>>().toEqualTypeOf<{
id: number
username: string
emails: MikroORMEntity<{ email: string; isVerified: boolean }>
}>()
})
test("define an entity with recursive relationships", () => {
const model = new EntityBuilder()
const order = model.define("order", {
amount: model.number(),
user: model.hasOne(() => user),
})
const user = model.define("user", {
id: model.number(),
username: model.text(),
orders: model.hasMany(() => order),
})
expectTypeOf<InstanceType<Infer<typeof user>>>().toMatchTypeOf<{
id: number
username: string
orders: MikroORMEntity<{
amount: number
user: MikroORMEntity<{
id: number
username: string
}>
}>
}>()
})
})

View File

@@ -0,0 +1,49 @@
import { expectTypeOf } from "expect-type"
import { EnumSchema } from "../schema/enum"
describe("Enum schema", () => {
test("create enum schema type", () => {
const schema = new EnumSchema(["admin", "moderator", "editor"])
expectTypeOf(schema["$dataType"]).toEqualTypeOf<
"admin" | "moderator" | "editor"
>()
expect(schema.parse("role")).toEqual({
fieldName: "role",
dataType: {
name: "enum",
options: {
choices: ["admin", "moderator", "editor"],
},
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
})
test("apply nullable and optional modifiers", () => {
const schema = new EnumSchema(["admin", "moderator", "editor"])
.nullable()
.optional()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<
"admin" | "moderator" | "editor" | null | undefined
>()
expect(schema.parse("role")).toEqual({
fieldName: "role",
dataType: {
name: "enum",
options: {
choices: ["admin", "moderator", "editor"],
},
},
nullable: true,
optional: true,
indexes: [],
relationships: [],
})
})
})

View File

@@ -0,0 +1,22 @@
import { expectTypeOf } from "expect-type"
import { HasMany } from "../relations/has_many"
import { TextSchema } from "../schema/text"
describe("HasMany relationship", () => {
test("define hasMany relationship", () => {
const user = {
username: new TextSchema(),
}
const entityRef = () => user
const relationship = new HasMany(entityRef, {})
expectTypeOf(relationship["$dataType"]).toEqualTypeOf<() => typeof user>()
expect(relationship.parse("user")).toEqual({
name: "user",
type: "hasMany",
entity: entityRef,
options: {},
})
})
})

View File

@@ -0,0 +1,22 @@
import { expectTypeOf } from "expect-type"
import { HasOne } from "../relations/has_one"
import { TextSchema } from "../schema/text"
describe("HasOne relationship", () => {
test("define hasOne relationship", () => {
const user = {
username: new TextSchema(),
}
const entityRef = () => user
const relationship = new HasOne(entityRef, {})
expectTypeOf(relationship["$dataType"]).toEqualTypeOf<() => typeof user>()
expect(relationship.parse("user")).toEqual({
name: "user",
type: "hasOne",
entity: entityRef,
options: {},
})
})
})

View File

@@ -0,0 +1,20 @@
import { expectTypeOf } from "expect-type"
import { JSONSchema } from "../schema/json"
describe("JSON schema", () => {
test("create json schema type", () => {
const schema = new JSONSchema()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<string>()
expect(schema.parse("coordinates")).toEqual({
fieldName: "coordinates",
dataType: {
name: "json",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
})
})

View File

@@ -0,0 +1,22 @@
import { expectTypeOf } from "expect-type"
import { TextSchema } from "../schema/text"
import { ManyToMany } from "../relations/many_to_many"
describe("ManyToMany relationship", () => {
test("define manyToMany relationship", () => {
const user = {
username: new TextSchema(),
}
const entityRef = () => user
const relationship = new ManyToMany(entityRef, {})
expectTypeOf(relationship["$dataType"]).toEqualTypeOf<() => typeof user>()
expect(relationship.parse("user")).toEqual({
name: "user",
type: "manyToMany",
entity: entityRef,
options: {},
})
})
})

View File

@@ -0,0 +1,20 @@
import { expectTypeOf } from "expect-type"
import { NumberSchema } from "../schema/number"
describe("Number schema", () => {
test("create number schema type", () => {
const schema = new NumberSchema()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<number>()
expect(schema.parse("age")).toEqual({
fieldName: "age",
dataType: {
name: "number",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
})
})

View File

@@ -0,0 +1,20 @@
import { expectTypeOf } from "expect-type"
import { TextSchema } from "../schema/text"
describe("String schema", () => {
test("create string schema type", () => {
const schema = new TextSchema()
expectTypeOf(schema["$dataType"]).toEqualTypeOf<string>()
expect(schema.parse("username")).toEqual({
fieldName: "username",
dataType: {
name: "string",
},
nullable: false,
optional: false,
indexes: [],
relationships: [],
})
})
})

View File

@@ -0,0 +1,13 @@
import { RelationshipType, SchemaType } from "./types"
export class DmlEntity<
Schema extends Record<string, SchemaType<any> | RelationshipType<any>>
> {
#name: string
#schema: Schema
constructor(name: string, schema: Schema) {
this.#name = name
this.#schema = schema
}
}

View File

@@ -0,0 +1,50 @@
import { DmlEntity } from "./entity"
import { TextSchema } from "./schema/text"
import { JSONSchema } from "./schema/json"
import { HasOne } from "./relations/has_one"
import { HasMany } from "./relations/has_many"
import { NumberSchema } from "./schema/number"
import { BooleanSchema } from "./schema/boolean"
import { DateTimeSchema } from "./schema/date_time"
import { ManyToMany } from "./relations/many_to_many"
import { RelationshipType, SchemaType } from "./types"
export class EntityBuilder {
define<
Schema extends Record<string, SchemaType<any> | RelationshipType<any>>
>(name: string, schema: Schema) {
return new DmlEntity(name, schema)
}
text() {
return new TextSchema()
}
boolean() {
return new BooleanSchema()
}
number() {
return new NumberSchema()
}
dateTime() {
return new DateTimeSchema()
}
json() {
return new JSONSchema()
}
hasOne<T>(entityBuilder: T, options?: Record<string, any>) {
return new HasOne<T>(entityBuilder, options || {})
}
hasMany<T>(entityBuilder: T, options?: Record<string, any>) {
return new HasMany<T>(entityBuilder, options || {})
}
manyToMany<T>(entityBuilder: T, options?: Record<string, any>) {
return new ManyToMany<T>(entityBuilder, options || {})
}
}

View File

@@ -0,0 +1,36 @@
import { SchemaType } from "../types"
import { OptionalModifier } from "./optional"
export class NullableModifier<T> {
/**
* A type-only property to infer the JavScript data-type
* of the schema property
*/
declare $dataType: T | null
/**
* The parent schema on which the nullable modifier is
* applied
*/
#schema: SchemaType<T>
constructor(schema: SchemaType<T>) {
this.#schema = schema
}
/**
* Apply optional modifier on the schema
*/
optional() {
return new OptionalModifier<T | null>(this)
}
/**
* Returns the serialized metadata
*/
parse(fieldName: string) {
const schema = this.#schema.parse(fieldName)
schema.nullable = true
return schema
}
}

View File

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,38 @@
import { RelationshipMetadata, RelationshipType } from "../types"
/**
* The BaseRelationship encapsulates the repetitive parts of defining
* a relationship
*/
export abstract class BaseRelationship<T> implements RelationshipType<T> {
#referencedEntity: T
#options: Record<string, any>
/**
* The relationship type.
*/
protected abstract relationshipType: RelationshipMetadata["type"]
/**
* A type-only property to infer the JavScript data-type
* of the relationship property
*/
declare $dataType: T
constructor(referencedEntity: T, options: Record<string, any>) {
this.#referencedEntity = referencedEntity
this.#options = options
}
/**
* Returns the parsed copy of the relationship
*/
parse(relationshipName: string): RelationshipMetadata {
return {
name: relationshipName,
entity: this.#referencedEntity,
options: this.#options,
type: this.relationshipType,
}
}
}

View File

@@ -0,0 +1,6 @@
import { BaseRelationship } from "./base"
import { RelationshipMetadata } from "../types"
export class HasMany<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "hasMany"
}

View File

@@ -0,0 +1,6 @@
import { BaseRelationship } from "./base"
import { RelationshipMetadata } from "../types"
export class HasOne<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "hasOne"
}

View File

@@ -0,0 +1,6 @@
import { BaseRelationship } from "./base"
import { RelationshipMetadata } from "../types"
export class ManyToMany<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "manyToMany"
}

View File

@@ -0,0 +1,52 @@
import { SchemaMetadata, SchemaType } from "../types"
import { NullableModifier } from "../modifiers/nullable"
import { OptionalModifier } from "../modifiers/optional"
/**
* The base schema class offers shared affordances to define
* schema classes
*/
export abstract class BaseSchema<T> implements SchemaType<T> {
#indexes: SchemaMetadata["indexes"] = []
#relationships: SchemaMetadata["relationships"] = []
/**
* The runtime dataType for the schema. It is not the same as
* the "$dataType".
*/
protected abstract dataType: SchemaMetadata["dataType"]
/**
* A type-only property to infer the JavScript data-type
* of the schema property
*/
declare $dataType: T
/**
* Apply nullable modifier on the schema
*/
nullable() {
return new NullableModifier<T>(this)
}
/**
* Apply optional modifier on the schema
*/
optional() {
return new OptionalModifier<T>(this)
}
/**
* Returns the serialized metadata
*/
parse(fieldName: string): SchemaMetadata {
return {
fieldName,
dataType: this.dataType,
nullable: false,
optional: false,
indexes: this.#indexes,
relationships: this.#relationships,
}
}
}

View File

@@ -0,0 +1,8 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
export class BooleanSchema extends BaseSchema<boolean> {
protected dataType: SchemaMetadata["dataType"] = {
name: "boolean",
}
}

View File

@@ -0,0 +1,8 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
export class DateTimeSchema extends BaseSchema<Date> {
protected dataType: SchemaMetadata["dataType"] = {
name: "dateTime",
}
}

View File

@@ -0,0 +1,15 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
export class EnumSchema<
const Values extends unknown
> extends BaseSchema<Values> {
protected dataType: SchemaMetadata["dataType"] = {
name: "enum",
}
constructor(values: Values[]) {
super()
this.dataType.options = { choices: values }
}
}

View File

@@ -0,0 +1,8 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
export class JSONSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "json",
}
}

View File

@@ -0,0 +1,8 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
export class NumberSchema extends BaseSchema<number> {
protected dataType: SchemaMetadata["dataType"] = {
name: "number",
}
}

View File

@@ -0,0 +1,8 @@
import { SchemaMetadata } from "../types"
import { BaseSchema } from "./base"
export class TextSchema extends BaseSchema<string> {
protected dataType: SchemaMetadata["dataType"] = {
name: "string",
}
}

View File

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

View File

@@ -0,0 +1,82 @@
import { DmlEntity } from "./entity"
/**
* The supported data types
*/
export type KnownDataTypes =
| "string"
| "boolean"
| "enum"
| "number"
| "dateTime"
| "json"
| "any"
/**
* The meta-data returned by the relationship parse
* method
*/
export type RelationshipMetadata = {
name: string
type: "hasOne" | "hasMany" | "manyToMany"
entity: unknown
options: Record<string, any>
}
/**
* The meta-data returned by the schema parse method
*/
export type SchemaMetadata = {
nullable: boolean
optional: boolean
fieldName: string
dataType: {
name: KnownDataTypes
options?: Record<string, any>
}
indexes: {
name: string
type: string
}[]
relationships: RelationshipMetadata[]
}
/**
* Definition of a schema type. It should have a parse
* method to get the metadata and a type-only property
* to get its static type
*/
export type SchemaType<T> = {
$dataType: T
parse(fieldName: string): SchemaMetadata
}
/**
* Definition of a relationship type. It should have a parse
* method to get the metadata and a type-only property
* to get its static type
*/
export type RelationshipType<T> = {
$dataType: T
parse(relationshipName: string): RelationshipMetadata
}
/**
* A type-only representation of a MikroORM entity. Since we generate
* entities on the fly, we need a way to represent a type-safe
* constructor and its instance properties.
*/
export interface MikroORMEntity<Props> extends Function {
new (): Props
}
/**
* Helper to infer the schema type of a DmlEntity
*/
export type Infer<T> = T extends DmlEntity<infer Schema>
? MikroORMEntity<{
[K in keyof Schema]: Schema[K]["$dataType"] extends () => infer R
? Infer<R>
: Schema[K]["$dataType"]
}>
: never