feat(utils): dml to graphql (#8951)

This commit is contained in:
Carlos R. L. Rodrigues
2024-09-04 06:15:07 -03:00
committed by GitHub
parent 419cf1b7d7
commit 5a097d8954
11 changed files with 482 additions and 81 deletions
@@ -1,5 +1,5 @@
import { loadResources } from "../load-internal"
import { IModuleService, ModuleResolution } from "@medusajs/types"
import { upperCaseFirst } from "@medusajs/utils"
import { join } from "path"
import {
ModuleWithDmlMixedWithoutJoinerConfigFixtures,
@@ -7,7 +7,7 @@ import {
ModuleWithJoinerConfigFixtures,
ModuleWithoutJoinerConfigFixtures,
} from "../__fixtures__"
import { upperCaseFirst } from "@medusajs/utils"
import { loadResources } from "../load-internal"
describe("load internal - load resources", () => {
describe("when loading the module resources from a path", () => {
@@ -64,30 +64,32 @@ describe("load internal - load resources", () => {
resources.moduleService.prototype as IModuleService
).__joinerConfig()
expect(generatedJoinerConfig).toEqual({
serviceName: "module-with-dml-mixed-without-joiner-config",
primaryKeys: ["id"],
linkableKeys: {
dml_entity_id: "DmlEntity",
entity_model_id: "EntityModel",
},
alias: [
{
name: ["dml_entity", "dml_entities"],
args: {
entity: "DmlEntity",
methodSuffix: "DmlEntities",
},
expect(generatedJoinerConfig).toEqual(
expect.objectContaining({
serviceName: "module-with-dml-mixed-without-joiner-config",
primaryKeys: ["id"],
linkableKeys: {
dml_entity_id: "DmlEntity",
entity_model_id: "EntityModel",
},
{
name: ["entity_model", "entity_models"],
args: {
entity: "EntityModel",
methodSuffix: "EntityModels",
alias: [
{
name: ["dml_entity", "dml_entities"],
args: {
entity: "DmlEntity",
methodSuffix: "DmlEntities",
},
},
},
],
})
{
name: ["entity_model", "entity_models"],
args: {
entity: "EntityModel",
methodSuffix: "EntityModels",
},
},
],
})
)
})
test("should return the correct resources and generate the correct joiner config from DML entities", async () => {
@@ -143,30 +145,32 @@ describe("load internal - load resources", () => {
resources.moduleService.prototype as IModuleService
).__joinerConfig()
expect(generatedJoinerConfig).toEqual({
serviceName: "module-with-dml-without-joiner-config",
primaryKeys: ["id"],
linkableKeys: {
entity_model_id: "EntityModel",
dml_entity_id: "DmlEntity",
},
alias: [
{
name: ["entity_model", "entity_models"],
args: {
entity: "EntityModel",
methodSuffix: "EntityModels",
},
expect(generatedJoinerConfig).toEqual(
expect.objectContaining({
serviceName: "module-with-dml-without-joiner-config",
primaryKeys: ["id"],
linkableKeys: {
entity_model_id: "EntityModel",
dml_entity_id: "DmlEntity",
},
{
name: ["dml_entity", "dml_entities"],
args: {
entity: "DmlEntity",
methodSuffix: "DmlEntities",
alias: [
{
name: ["entity_model", "entity_models"],
args: {
entity: "EntityModel",
methodSuffix: "EntityModels",
},
},
},
],
})
{
name: ["dml_entity", "dml_entities"],
args: {
entity: "DmlEntity",
methodSuffix: "DmlEntities",
},
},
],
})
)
})
test("should return the correct resources and generate the correct joiner config from mikro orm entities", async () => {
@@ -229,6 +233,7 @@ describe("load internal - load resources", () => {
entity2_id: "Entity2",
entity_model_id: "EntityModel",
},
schema: "",
alias: [
{
name: ["entity2", "entity2s"],
@@ -301,6 +306,7 @@ describe("load internal - load resources", () => {
serviceName: "module-service",
primaryKeys: ["id"],
linkableKeys: {},
schema: "",
alias: [
{
name: ["custom_name"],
@@ -0,0 +1,91 @@
import { model } from "../entity-builder"
import { toGraphQLSchema } from "../helpers/create-graphql"
describe("GraphQL builder", () => {
test("define an entity", () => {
const tag = model.define("tag", {
id: model.id(),
value: model.text(),
})
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
})
const user = model.define("user", {
id: model.id(),
username: model.text(),
email: model.hasOne(() => email, { mappedBy: "owner" }),
spend_limit: model.bigNumber(),
phones: model.array(),
group: model.belongsTo(() => group, { mappedBy: "users" }),
role: model.enum(["moderator", "admin", "guest"]).default("guest"),
tags: model.manyToMany(() => tag, {
pivotTable: "custom_user_tags",
}),
})
const group = model.define("group", {
id: model.number(),
name: model.text(),
users: model.hasMany(() => user),
})
const toGql = toGraphQLSchema([tag, email, user, group])
const expected = `
type Tag {
id: ID!
value: String!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
type Email {
email: String!
isVerified: Boolean!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
extend type Email {
owner: User!
}
enum UserRoleEnum {
moderator
admin
guest
}
type User {
id: ID!
username: String!
email: Email!
spend_limit: String!
phones: [String]!
group: [Group]!
role: UserRoleEnum!
tags: [Tag]!
raw_spend_limit: JSON!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
type Group {
id: Int!
name: String!
users: [User]!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
`
expect(toGql.replace(/\s/g, "")).toEqual(expected.replace(/\s/g, ""))
})
})
@@ -2131,7 +2131,7 @@ describe("Entity builder", () => {
entity: "Email",
nullable: false,
mappedBy: "user",
cascade: ["perist", "soft-remove"],
cascade: ["persist", "soft-remove"],
},
created_at: {
reference: "scalar",
@@ -2284,7 +2284,7 @@ describe("Entity builder", () => {
entity: "Email",
nullable: false,
mappedBy: "user",
cascade: ["perist", "soft-remove"],
cascade: ["persist", "soft-remove"],
},
created_at: {
reference: "scalar",
@@ -2891,7 +2891,7 @@ describe("Entity builder", () => {
entity: "Email",
orphanRemoval: true,
mappedBy: "user",
cascade: ["perist", "soft-remove"],
cascade: ["persist", "soft-remove"],
},
created_at: {
reference: "scalar",
@@ -2981,7 +2981,7 @@ describe("Entity builder", () => {
entity: "Email",
orphanRemoval: true,
mappedBy: "user",
cascade: ["perist", "soft-remove"],
cascade: ["persist", "soft-remove"],
},
created_at: {
reference: "scalar",
@@ -0,0 +1,63 @@
import type { PropertyType } from "@medusajs/types"
import { DmlEntity } from "../entity"
import { parseEntityName } from "./entity-builder/parse-entity-name"
import { getGraphQLAttributeFromDMLPropety } from "./graphql-builder/get-attribute"
import { setGraphQLRelationship } from "./graphql-builder/set-relationship"
export function generateGraphQLFromEntity<T extends DmlEntity<any, any>>(
entity: T
): string {
const { schema } = entity.parse()
const { modelName } = parseEntityName(entity)
let extra: string[] = []
let gqlSchema: string[] = []
Object.entries(schema).forEach(([name, property]) => {
const field = property.parse(name)
if ("fieldName" in field) {
const prop = getGraphQLAttributeFromDMLPropety(
modelName,
name,
property as PropertyType<any>
)
if (prop.enum) {
extra.push(prop.enum)
}
gqlSchema.push(`${prop.attribute}`)
} else {
const prop = setGraphQLRelationship(modelName, field)
if (prop.extra) {
extra.push(prop.extra)
}
gqlSchema.push(`${prop.attribute}`)
}
})
return `
${extra.join("\n")}
type ${modelName} {
${gqlSchema.join("\n")}
}
`
}
/**
* Takes a DML entity and returns a GraphQL schema string.
* @param entity
*/
export const toGraphQLSchema = <T extends any[]>(entities: T): string => {
const gqlSchemas = entities.map((entity) => {
if (DmlEntity.isDmlEntity(entity)) {
return generateGraphQLFromEntity(entity)
}
return entity
})
return gqlSchemas.join("\n")
}
@@ -8,13 +8,13 @@ import type {
import { Entity, Filter } from "@mikro-orm/core"
import { mikroOrmSoftDeletableFilterOptions } from "../../dal"
import { DmlEntity } from "../entity"
import { DuplicateIdPropertyError } from "../errors"
import { IdProperty } from "../properties/id"
import { applySearchable } from "./entity-builder/apply-searchable"
import { defineProperty } from "./entity-builder/define-property"
import { defineRelationship } from "./entity-builder/define-relationship"
import { parseEntityName } from "./entity-builder/parse-entity-name"
import { applyEntityIndexes, applyIndexes } from "./mikro-orm/apply-indexes"
import { IdProperty } from "../properties/id"
import { DuplicateIdPropertyError } from "../errors"
/**
* Factory function to create the mikro orm entity builder. The return
@@ -36,7 +36,7 @@ export function createMikrORMEntity() {
* - [team.users] // cannot be an owner
*/
// TODO: if we use the util toMikroOrmEntities then a new builder will be used each time, lets think about this. Currently if means that with many to many we need to use the same builder
const MANY_TO_MANY_TRACKED_REALTIONS: Record<string, boolean> = {}
const MANY_TO_MANY_TRACKED_RELATIONS: Record<string, boolean> = {}
/**
* A helper function to define a Mikro ORM entity from a
@@ -61,7 +61,7 @@ export function createMikrORMEntity() {
})
const context = {
MANY_TO_MANY_TRACKED_REALTIONS,
MANY_TO_MANY_TRACKED_RELATIONS,
}
let hasIdAlreadyDefined = false
@@ -9,9 +9,9 @@ import {
BeforeCreate,
ManyToMany,
ManyToOne,
OnInit,
OneToMany,
OneToOne,
OnInit,
Property,
rel,
} from "@mikro-orm/core"
@@ -24,7 +24,7 @@ import { ManyToMany as DmlManyToMany } from "../../relations/many-to-many"
import { parseEntityName } from "./parse-entity-name"
type Context = {
MANY_TO_MANY_TRACKED_REALTIONS: Record<string, boolean>
MANY_TO_MANY_TRACKED_RELATIONS: Record<string, boolean>
}
/**
@@ -43,7 +43,7 @@ export function defineHasOneRelationship(
nullable: relationship.nullable,
mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name),
cascade: shouldRemoveRelated
? (["perist", "soft-remove"] as any)
? (["persist", "soft-remove"] as any)
: undefined,
})(MikroORMEntity.prototype, relationship.name)
}
@@ -64,7 +64,7 @@ export function defineHasManyRelationship(
orphanRemoval: true,
mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name),
cascade: shouldRemoveRelated
? (["perist", "soft-remove"] as any)
? (["persist", "soft-remove"] as any)
: undefined,
})(MikroORMEntity.prototype, relationship.name)
}
@@ -238,7 +238,7 @@ export function defineManyToManyRelationship(
relatedModelName,
pgSchema,
}: { relatedModelName: string; pgSchema: string | undefined },
{ MANY_TO_MANY_TRACKED_REALTIONS }: Context
{ MANY_TO_MANY_TRACKED_RELATIONS }: Context
) {
let mappedBy = relationship.mappedBy
let inversedBy: undefined | string
@@ -271,12 +271,12 @@ export function defineManyToManyRelationship(
*/
if (
otherSideRelation.parse(mappedBy).mappedBy &&
MANY_TO_MANY_TRACKED_REALTIONS[`${relatedModelName}.${mappedBy}`]
MANY_TO_MANY_TRACKED_RELATIONS[`${relatedModelName}.${mappedBy}`]
) {
inversedBy = mappedBy
mappedBy = undefined
} else {
MANY_TO_MANY_TRACKED_REALTIONS[
MANY_TO_MANY_TRACKED_RELATIONS[
`${MikroORMEntity.name}.${relationship.name}`
] = true
}
@@ -0,0 +1,81 @@
import { PropertyType } from "@medusajs/types"
import { toPascalCase } from "../../../common"
import { PrimaryKeyModifier } from "../../properties/primary-key"
/*
* Map of DML data types to GraphQL types
*/
const GRAPHQL_TYPES = {
boolean: "Boolean",
dateTime: "DateTime",
number: "Int",
bigNumber: "String",
text: "String",
json: "JSON",
array: "[String]",
id: "ID",
}
/**
* Defines a DML entity schema field as a Mikro ORM property
*/
export function getGraphQLAttributeFromDMLPropety(
modelName: string,
propertyName: string,
property: PropertyType<any>
): {
enum?: string
attribute: string
} {
const field = property.parse(propertyName)
const fieldType = PrimaryKeyModifier.isPrimaryKeyModifier(property)
? "id"
: field.dataType.name
let enumSchema: string | undefined
let gqlAttr: {
property: string
type: string
}
const specialType = {
enum: () => {
const enumName = toPascalCase(modelName + "_" + field.fieldName + "Enum")
const enumValues = field.dataType
.options!.choices.map((value) => {
return ` ${value}`
})
.join("\n")
enumSchema = `enum ${enumName} {\n${enumValues}\n}`
gqlAttr = {
property: field.fieldName,
type: enumName,
}
},
id: () => {
gqlAttr = {
property: field.fieldName,
type: GRAPHQL_TYPES.id,
}
},
}
if (specialType[fieldType]) {
specialType[fieldType]()
} else {
gqlAttr = {
property: field.fieldName,
type: GRAPHQL_TYPES[fieldType] ?? "String",
}
}
if (!field.nullable) {
gqlAttr!.type += "!"
}
return {
enum: enumSchema,
attribute: `${gqlAttr!.property}: ${gqlAttr!.type}`,
}
}
@@ -0,0 +1,100 @@
import {
PropertyType,
RelationshipMetadata,
RelationshipType,
} from "@medusajs/types"
import { camelToSnakeCase } from "../../../common"
import { DmlEntity } from "../../entity"
import { BelongsTo } from "../../relations/belongs-to"
import { HasMany } from "../../relations/has-many"
import { HasOne } from "../../relations/has-one"
import { ManyToMany as DmlManyToMany } from "../../relations/many-to-many"
import { parseEntityName } from "../entity-builder/parse-entity-name"
type Context = {
MANY_TO_MANY_TRACKED_RELATIONS: Record<string, boolean>
}
function defineRelationships(
modelName: string,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, PropertyType<any> | RelationshipType<any>>,
any
>,
{ relatedModelName }: { relatedModelName: string }
) {
let extra: string | undefined
const fieldName = relationship.name
const mappedBy = relationship.mappedBy || camelToSnakeCase(modelName)
const { schema: relationSchema } = relatedEntity.parse()
const otherSideRelation = relationSchema[mappedBy]
if (relationship.options?.mappedBy && HasOne.isHasOne(relationship)) {
const otherSideFieldName = relationship.options.mappedBy
extra = `extend type ${relatedModelName} {\n ${otherSideFieldName}: ${modelName}!\n}`
}
let isArray = false
/**
* Otherside is a has many. Hence we should defined a ManyToOne
*/
if (
HasMany.isHasMany(otherSideRelation) ||
DmlManyToMany.isManyToMany(relationship) ||
(BelongsTo.isBelongsTo(otherSideRelation) &&
HasMany.isHasMany(relationship))
) {
isArray = true
}
return {
attribute:
`${fieldName}: ${isArray ? "[" : ""}${relatedModelName}${
isArray ? "]" : ""
}` + (relationship.nullable ? "" : "!"),
extra,
}
}
export function setGraphQLRelationship(
entityName: string,
relationship: RelationshipMetadata
): {
extra?: string
attribute: string
} {
const relatedEntity =
typeof relationship.entity === "function"
? (relationship.entity() as unknown)
: undefined
if (!relatedEntity) {
throw new Error(
`Invalid relationship reference for "${entityName}.${relationship.name}". Make sure to define the relationship using a factory function`
)
}
if (!DmlEntity.isDmlEntity(relatedEntity)) {
throw new Error(
`Invalid relationship reference for "${entityName}.${relationship.name}". Make sure to return a DML entity from the relationship callback`
)
}
const { modelName, tableName, pgSchema } = parseEntityName(relatedEntity)
const relatedEntityInfo = {
relatedModelName: modelName,
relatedTableName: tableName,
pgSchema,
}
return defineRelationships(
entityName,
relationship,
relatedEntity,
relatedEntityInfo
)
}
@@ -1,13 +1,6 @@
import {
buildLinkableKeysFromDmlObjects,
buildLinkableKeysFromMikroOrmObjects,
buildLinkConfigFromLinkableKeys,
buildLinkConfigFromModelObjects,
defineJoinerConfig,
} from "../joiner-config-builder"
import { Modules } from "../definition"
import { model } from "../../dml"
import { expectTypeOf } from "expect-type"
import { upperCaseFirst } from "../../common"
import { model } from "../../dml"
import {
dmlFulfillment,
dmlFulfillmentProvider,
@@ -26,7 +19,14 @@ import {
ShippingOptionRule,
ShippingProfile,
} from "../__fixtures__/joiner-config/entities"
import { upperCaseFirst } from "../../common"
import { Modules } from "../definition"
import {
buildLinkableKeysFromDmlObjects,
buildLinkableKeysFromMikroOrmObjects,
buildLinkConfigFromLinkableKeys,
buildLinkConfigFromModelObjects,
defineJoinerConfig,
} from "../joiner-config-builder"
describe("joiner-config-builder", () => {
describe("defineJoiner | Mikro orm objects", () => {
@@ -47,7 +47,7 @@ describe("joiner-config-builder", () => {
expect(joinerConfig).toEqual({
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: undefined,
schema: "",
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
shipping_option_id: ShippingOption.name,
@@ -135,7 +135,7 @@ describe("joiner-config-builder", () => {
expect(joinerConfig).toEqual({
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: undefined,
schema: "",
linkableKeys: {},
alias: [
{
@@ -175,7 +175,7 @@ describe("joiner-config-builder", () => {
expect(joinerConfig).toEqual({
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: undefined,
schema: "",
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
shipping_option_id: ShippingOption.name,
@@ -269,7 +269,7 @@ describe("joiner-config-builder", () => {
expect(joinerConfig).toEqual({
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: undefined,
schema: "",
linkableKeys: {},
alias: [
{
@@ -300,7 +300,7 @@ describe("joiner-config-builder", () => {
expect(joinerConfig).toEqual({
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: undefined,
schema: "",
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
},
@@ -335,7 +335,7 @@ describe("joiner-config-builder", () => {
expect(joinerConfig).toEqual({
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: undefined,
schema: expect.any(String),
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
shipping_option_id: ShippingOption.name,
@@ -405,6 +405,59 @@ describe("joiner-config-builder", () => {
},
],
})
const schemaExpected = `type FulfillmentSet {
id: ID!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
type ShippingOption {
id: ID!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
type ShippingProfile {
id: ID!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
type Fulfillment {
id: ID!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
type FulfillmentProvider {
id: ID!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
type ServiceZone {
id: ID!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
type GeoZone {
id: ID!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}
type ShippingOptionRule {
id: ID!
created_at: DateTime!
updated_at: DateTime!
deleted_at: DateTime
}`
expect(joinerConfig.schema!.replace(/\s/g, "")).toEqual(
schemaExpected.replace(/\s/g, "")
)
})
})
@@ -4,25 +4,26 @@ import {
ModuleJoinerConfig,
PropertyType,
} from "@medusajs/types"
import { accessSync } from "fs"
import * as path from "path"
import { dirname, join, normalize } from "path"
import {
MapToConfig,
camelToSnakeCase,
deduplicate,
getCallerFilePath,
isObject,
lowerCaseFirst,
MapToConfig,
pluralize,
toCamelCase,
upperCaseFirst,
} from "../common"
import { loadModels } from "./loaders/load-models"
import { DmlEntity } from "../dml"
import { BaseRelationship } from "../dml/relations/base"
import { toGraphQLSchema } from "../dml/helpers/create-graphql"
import { PrimaryKeyModifier } from "../dml/properties/primary-key"
import { BaseRelationship } from "../dml/relations/base"
import { loadModels } from "./loaders/load-models"
import { InferLinkableKeys, InfersLinksConfig } from "./types/links-config"
import { accessSync } from "fs"
/**
* Define joiner config for a module based on the models (object representation or entities) present in the models directory. This action will be sync until
@@ -146,6 +147,10 @@ export function defineJoinerConfig(
deduplicatedLoadedModels.push(model)
})
if (!schema) {
schema = toGraphQLSchema([...modelDefinitions.values()])
}
if (!linkableKeys) {
const linkableKeysFromDml = buildLinkableKeysFromDmlObjects([
...modelDefinitions.values(),
@@ -1,6 +1,8 @@
import { defineJoinerConfig, Modules } from "@medusajs/utils"
import { default as schema } from "./schema"
export const joinerConfig = defineJoinerConfig(Modules.INVENTORY, {
schema,
alias: [
{
name: ["inventory_items", "inventory_item", "inventory"],