diff --git a/.changeset/calm-spiders-beg.md b/.changeset/calm-spiders-beg.md new file mode 100644 index 0000000000..a5eb1af7bf --- /dev/null +++ b/.changeset/calm-spiders-beg.md @@ -0,0 +1,6 @@ +--- +"@medusajs/utils": patch +"@medusajs/modules-sdk": patch +--- + +feat(utils): Convert remote query object to string array of fields and relations from GQL schema diff --git a/packages/modules-sdk/package.json b/packages/modules-sdk/package.json index f1779a018c..973263475a 100644 --- a/packages/modules-sdk/package.json +++ b/packages/modules-sdk/package.json @@ -24,6 +24,8 @@ "typescript": "^5.1.6" }, "dependencies": { + "@graphql-tools/merge": "^9.0.0", + "@graphql-tools/schema": "^10.0.0", "@medusajs/orchestration": "^0.4.1", "@medusajs/types": "^1.11.2", "@medusajs/utils": "^1.10.2", diff --git a/packages/modules-sdk/src/__tests__/utils/get-fields-and-relations.spec.ts b/packages/modules-sdk/src/__tests__/utils/get-fields-and-relations.spec.ts new file mode 100644 index 0000000000..0462837b3c --- /dev/null +++ b/packages/modules-sdk/src/__tests__/utils/get-fields-and-relations.spec.ts @@ -0,0 +1,94 @@ +import { mergeTypeDefs } from "@graphql-tools/merge" +import { makeExecutableSchema } from "@graphql-tools/schema" +import { getFieldsAndRelations } from "../../utils" + +const userModule = ` +type User { + id: ID! + name: String! + blabla: WHATEVER +} + +type Post { + author: User! +} +` + +const postModule = ` +type Post { + id: ID! + title: String! + date: String +} + +type User { + posts: [Post!]! +} + +type WHATEVER { + random_field: String + post: Post +} +` + +const mergedSchema = mergeTypeDefs([userModule, postModule]) +const schema = makeExecutableSchema({ + typeDefs: mergedSchema, +}) + +const types = schema.getTypeMap() + +describe("getFieldsAndRelations", function () { + it("Should get all fields of a given entity", async function () { + const fields = getFieldsAndRelations(types, "User") + expect(fields).toEqual(expect.arrayContaining(["id", "name"])) + }) + + it("Should get all fields of a given entity and a relation", async function () { + const fields = getFieldsAndRelations(types, "User", ["posts"]) + expect(fields).toEqual( + expect.arrayContaining([ + "id", + "name", + "posts.id", + "posts.title", + "posts.date", + ]) + ) + }) + + it("Should get all fields of a given entity and many relations", async function () { + const fields = getFieldsAndRelations(types, "User", [ + "posts", + "blabla", + "blabla.post", + ]) + expect(fields).toEqual( + expect.arrayContaining([ + "id", + "name", + "posts.id", + "posts.title", + "posts.date", + "blabla.random_field", + "blabla.post.id", + "blabla.post.title", + "blabla.post.date", + ]) + ) + }) + + it("Should get all fields of a given entity and many relations limited to the relations given", async function () { + const fields = getFieldsAndRelations(types, "User", ["posts", "blabla"]) + expect(fields).toEqual( + expect.arrayContaining([ + "id", + "name", + "posts.id", + "posts.title", + "posts.date", + "blabla.random_field", + ]) + ) + }) +}) diff --git a/packages/modules-sdk/src/__tests__/utils/graphql-schema-to-fields.ts b/packages/modules-sdk/src/__tests__/utils/graphql-schema-to-fields.ts new file mode 100644 index 0000000000..da14d6dd7b --- /dev/null +++ b/packages/modules-sdk/src/__tests__/utils/graphql-schema-to-fields.ts @@ -0,0 +1,110 @@ +import { mergeTypeDefs } from "@graphql-tools/merge" +import { makeExecutableSchema } from "@graphql-tools/schema" +import { graphqlSchemaToFields } from "../../utils" + +const userModule = ` +type User { + id: ID! + name: String! + blabla: WHATEVER +} + +type Post { + author: User! +} +` + +const postModule = ` +type Post { + id: ID! + title: String! + date: String +} + +type User { + posts: [Post!]! +} + +type WHATEVER { + random_field: String + post: Post +} +` + +const mergedSchema = mergeTypeDefs([userModule, postModule]) +const schema = makeExecutableSchema({ + typeDefs: mergedSchema, +}) + +const types = schema.getTypeMap() + +describe("graphqlSchemaToFields", function () { + it("Should get all fields of a given entity", async function () { + const fields = graphqlSchemaToFields(types, "User") + expect(fields).toEqual(expect.arrayContaining(["id", "name"])) + }) + + it("Should get all fields of a given entity and a relation", async function () { + const fields = graphqlSchemaToFields(types, "User", ["posts"]) + expect(fields).toEqual( + expect.arrayContaining([ + "id", + "name", + "posts.id", + "posts.title", + "posts.date", + ]) + ) + }) + + it("Should get all fields of a given entity and many relations", async function () { + const fields = graphqlSchemaToFields(types, "User", [ + "posts", + "blabla", + "blabla.post", + ]) + expect(fields).toEqual( + expect.arrayContaining([ + "id", + "name", + "posts.id", + "posts.title", + "posts.date", + "blabla.random_field", + "blabla.post.id", + "blabla.post.title", + "blabla.post.date", + ]) + ) + }) + + it("Should get all fields of a given entity and many relations limited to the relations given", async function () { + const fields = graphqlSchemaToFields(types, "User", ["posts", "blabla"]) + expect(fields).toEqual( + expect.arrayContaining([ + "id", + "name", + "posts.id", + "posts.title", + "posts.date", + "blabla.random_field", + ]) + ) + }) + + it("Should get all fields of a given entity and many relations limited to the relations given if they exists", async function () { + const fields = graphqlSchemaToFields(types, "User", [ + "posts", + "doNotExists", + ]) + expect(fields).toEqual( + expect.arrayContaining([ + "id", + "name", + "posts.id", + "posts.title", + "posts.date", + ]) + ) + }) +}) diff --git a/packages/modules-sdk/src/utils/get-fields-and-relations.ts b/packages/modules-sdk/src/utils/get-fields-and-relations.ts new file mode 100644 index 0000000000..e5edd9e3f8 --- /dev/null +++ b/packages/modules-sdk/src/utils/get-fields-and-relations.ts @@ -0,0 +1,37 @@ +import { GraphQLNamedType, GraphQLObjectType, isObjectType } from "graphql" + +export function getFieldsAndRelations( + schemaTypeMap: { [key: string]: GraphQLNamedType }, + typeName: string, + relations: string[] = [] +) { + const result: string[] = [] + + function traverseFields(typeName, prefix) { + const type = schemaTypeMap[typeName] + + if (!(type instanceof GraphQLObjectType)) { + return + } + + const fields = type.getFields() + + for (const fieldName in fields) { + const field = fields[fieldName] + let fieldType = field.type as any + + while (fieldType.ofType) { + fieldType = fieldType.ofType + } + + if (!isObjectType(fieldType)) { + result.push(`${prefix}${fieldName}`) + } else if (relations.includes(prefix + fieldName)) { + traverseFields(fieldType.name, `${prefix}${fieldName}.`) + } + } + } + + traverseFields(typeName, "") + return result +} diff --git a/packages/modules-sdk/src/utils/graphql-schema-to-fields.ts b/packages/modules-sdk/src/utils/graphql-schema-to-fields.ts new file mode 100644 index 0000000000..ab251489e0 --- /dev/null +++ b/packages/modules-sdk/src/utils/graphql-schema-to-fields.ts @@ -0,0 +1,93 @@ +import { GraphQLNamedType, GraphQLObjectType, isObjectType } from "graphql" + +/** + * From graphql schema get all the fields for the requested type and relations + * + * @param schemaTypeMap + * @param typeName + * @param relations + * + * @example + * + * const userModule = ` + * type User { + * id: ID! + * name: String! + * blabla: WHATEVER + * } + * + * type Post { + * author: User! + * } + * ` + * + * const postModule = ` + * type Post { + * id: ID! + * title: String! + * date: String + * } + * + * type User { + * posts: [Post!]! + * } + * + * type WHATEVER { + * random_field: String + * post: Post + * } + * ` + * + * const mergedSchema = mergeTypeDefs([userModule, postModule]) + * const schema = makeExecutableSchema({ + * typeDefs: mergedSchema, + * }) + * + * const fields = graphqlSchemaToFields(types, "User", ["posts"]) + * + * console.log(fields) + * + * // [ + * // "id", + * // "name", + * // "posts.id", + * // "posts.title", + * // "posts.date", + * // ] + */ +export function graphqlSchemaToFields( + schemaTypeMap: { [key: string]: GraphQLNamedType }, + typeName: string, + relations: string[] = [] +) { + const result: string[] = [] + + function traverseFields(typeName, parent = "") { + const type = schemaTypeMap[typeName] + + if (!(type instanceof GraphQLObjectType)) { + return + } + + const fields = type.getFields() + + for (const fieldName in fields) { + const field = fields[fieldName] + let fieldType = field.type as any + + while (fieldType.ofType) { + fieldType = fieldType.ofType + } + + const composedField = parent ? `${parent}.${fieldName}` : fieldName + if (!isObjectType(fieldType)) { + result.push(composedField) + } else if (relations.includes(composedField)) { + traverseFields(fieldType.name, composedField) + } + } + } + + traverseFields(typeName) + return result +} diff --git a/packages/modules-sdk/src/utils/index.ts b/packages/modules-sdk/src/utils/index.ts new file mode 100644 index 0000000000..55667e54ae --- /dev/null +++ b/packages/modules-sdk/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./get-fields-and-relations" +export * from "./graphql-schema-to-fields" + diff --git a/packages/utils/src/common/__tests__/string-to-remote-query-object.ts b/packages/utils/src/common/__tests__/remote-query-object-from-string.spec.ts similarity index 89% rename from packages/utils/src/common/__tests__/string-to-remote-query-object.ts rename to packages/utils/src/common/__tests__/remote-query-object-from-string.spec.ts index 0314ba0268..5cbd967574 100644 --- a/packages/utils/src/common/__tests__/string-to-remote-query-object.ts +++ b/packages/utils/src/common/__tests__/remote-query-object-from-string.spec.ts @@ -1,4 +1,4 @@ -import { stringToRemoteQueryObject } from "../string-to-remote-query-object" +import { remoteQueryObjectFromString } from "../remote-query-object-from-string" const fields = [ "id", @@ -29,9 +29,9 @@ const fields = [ "options.values.metadata", ] -describe("stringToRemoteQueryObject", function () { +describe("remoteQueryObjectFromString", function () { it("should return a remote query object", function () { - const output = stringToRemoteQueryObject({ + const output = remoteQueryObjectFromString({ entryPoint: "product", variables: {}, fields, diff --git a/packages/utils/src/common/__tests__/remote-query-object-to-string.spec.ts b/packages/utils/src/common/__tests__/remote-query-object-to-string.spec.ts new file mode 100644 index 0000000000..fbff611a6a --- /dev/null +++ b/packages/utils/src/common/__tests__/remote-query-object-to-string.spec.ts @@ -0,0 +1,219 @@ +import { remoteQueryObjectToString } from "../remote-query-object-to-string" + +const remoteQueryObject = { + fields: [ + "id", + "title", + "subtitle", + "status", + "external_id", + "description", + "handle", + "is_giftcard", + "discountable", + "thumbnail", + "collection_id", + "type_id", + "weight", + "length", + "height", + "width", + "hs_code", + "origin_country", + "mid_code", + "material", + "created_at", + "updated_at", + "deleted_at", + "metadata", + ], + images: { + fields: ["id", "created_at", "updated_at", "deleted_at", "url", "metadata"], + }, + tags: { + fields: ["id", "created_at", "updated_at", "deleted_at", "value"], + }, + + type: { + fields: ["id", "created_at", "updated_at", "deleted_at", "value"], + }, + + collection: { + fields: ["title", "handle", "id", "created_at", "updated_at", "deleted_at"], + }, + + options: { + fields: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "title", + "product_id", + "metadata", + ], + values: { + fields: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "value", + "option_id", + "variant_id", + "metadata", + ], + }, + }, + + variants: { + fields: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "title", + "product_id", + "sku", + "barcode", + "ean", + "upc", + "variant_rank", + "inventory_quantity", + "allow_backorder", + "manage_inventory", + "hs_code", + "origin_country", + "mid_code", + "material", + "weight", + "length", + "height", + "width", + "metadata", + ], + + options: { + fields: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "value", + "option_id", + "variant_id", + "metadata", + ], + }, + }, + profile: { + fields: ["id", "created_at", "updated_at", "deleted_at", "name", "type"], + }, +} + +describe("remoteQueryObjectToString", function () { + it("should return a string array of fields/relations", function () { + const output = remoteQueryObjectToString(remoteQueryObject) + + expect(output).toEqual([ + "id", + "title", + "subtitle", + "status", + "external_id", + "description", + "handle", + "is_giftcard", + "discountable", + "thumbnail", + "collection_id", + "type_id", + "weight", + "length", + "height", + "width", + "hs_code", + "origin_country", + "mid_code", + "material", + "created_at", + "updated_at", + "deleted_at", + "metadata", + "images.id", + "images.created_at", + "images.updated_at", + "images.deleted_at", + "images.url", + "images.metadata", + "tags.id", + "tags.created_at", + "tags.updated_at", + "tags.deleted_at", + "tags.value", + "type.id", + "type.created_at", + "type.updated_at", + "type.deleted_at", + "type.value", + "collection.title", + "collection.handle", + "collection.id", + "collection.created_at", + "collection.updated_at", + "collection.deleted_at", + "options.id", + "options.created_at", + "options.updated_at", + "options.deleted_at", + "options.title", + "options.product_id", + "options.metadata", + "options.values.id", + "options.values.created_at", + "options.values.updated_at", + "options.values.deleted_at", + "options.values.value", + "options.values.option_id", + "options.values.variant_id", + "options.values.metadata", + "variants.id", + "variants.created_at", + "variants.updated_at", + "variants.deleted_at", + "variants.title", + "variants.product_id", + "variants.sku", + "variants.barcode", + "variants.ean", + "variants.upc", + "variants.variant_rank", + "variants.inventory_quantity", + "variants.allow_backorder", + "variants.manage_inventory", + "variants.hs_code", + "variants.origin_country", + "variants.mid_code", + "variants.material", + "variants.weight", + "variants.length", + "variants.height", + "variants.width", + "variants.metadata", + "variants.options.id", + "variants.options.created_at", + "variants.options.updated_at", + "variants.options.deleted_at", + "variants.options.value", + "variants.options.option_id", + "variants.options.variant_id", + "variants.options.metadata", + "profile.id", + "profile.created_at", + "profile.updated_at", + "profile.deleted_at", + "profile.name", + "profile.type", + ]) + }) +}) diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index 2b4c8fb071..70b50d72ec 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -16,8 +16,11 @@ export * from "./map-object-to" export * from "./medusa-container" export * from "./object-from-string-path" export * from "./object-to-string-path" +export * from "./remote-query-object-from-string" +export * from "./remote-query-object-to-string" export * from "./set-metadata" export * from "./simple-hash" +export * from "./string-to-select-relation-object" export * from "./stringify-circular" export * from "./to-camel-case" export * from "./to-kebab-case" @@ -25,5 +28,3 @@ export * from "./to-pascal-case" export * from "./transaction" export * from "./upper-case-first" export * from "./wrap-handler" -export * from "./string-to-remote-query-object" -export * from "./string-to-select-relation-object" diff --git a/packages/utils/src/common/string-to-remote-query-object.ts b/packages/utils/src/common/remote-query-object-from-string.ts similarity index 96% rename from packages/utils/src/common/string-to-remote-query-object.ts rename to packages/utils/src/common/remote-query-object-from-string.ts index cc76aed7b8..1a880c7344 100644 --- a/packages/utils/src/common/string-to-remote-query-object.ts +++ b/packages/utils/src/common/remote-query-object-from-string.ts @@ -34,7 +34,7 @@ * "options.values.metadata", * ] * - * const remoteQueryObject = stringToRemoteQueryObject({ + * const remoteQueryObject = remoteQueryObjectFromString({ * entryPoint: "product", * variables: {}, * fields, @@ -83,7 +83,7 @@ * // }, * // } */ -export function stringToRemoteQueryObject({ +export function remoteQueryObjectFromString({ entryPoint, variables, fields, diff --git a/packages/utils/src/common/remote-query-object-to-string.ts b/packages/utils/src/common/remote-query-object-to-string.ts new file mode 100644 index 0000000000..c0682b2697 --- /dev/null +++ b/packages/utils/src/common/remote-query-object-to-string.ts @@ -0,0 +1,51 @@ +/** + * Transform a remote query object to a string array containing the chain of fields and relations + * + * @param fields + * @param parent + * + * @example + * + * const remoteQueryObject = { + * fields: [ + * "id", + * "title", + * ], + * images: { + * fields: ["id", "created_at", "updated_at", "deleted_at", "url", "metadata"], + * }, + * } + * + * const fields = remoteQueryObjectToString(remoteQueryObject) + * + * console.log(fields) + * // ["id", "title", "images.id", "images.created_at", "images.updated_at", "images.deleted_at", "images.url", "images.metadata"] + */ +export function remoteQueryObjectToString( + fields: object, + parent?: string +): string[] { + return Object.keys(fields).reduce((acc, key) => { + if (key === "fields") { + if (parent) { + fields[key].map((fieldKey) => acc.push(`${parent}.${fieldKey}`)) + } else { + fields[key].map((fieldKey) => acc.push(fieldKey)) + } + + return acc + } + + if (typeof fields[key] === "object") { + acc = acc.concat( + remoteQueryObjectToString( + fields[key], + parent ? `${parent}.${key}` : key + ) + ) + return acc + } + + return acc + }, [] as string[]) +} diff --git a/yarn.lock b/yarn.lock index 4e8744ffd9..efec945468 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4644,6 +4644,18 @@ __metadata: languageName: node linkType: hard +"@graphql-tools/merge@npm:^9.0.0": + version: 9.0.0 + resolution: "@graphql-tools/merge@npm:9.0.0" + dependencies: + "@graphql-tools/utils": ^10.0.0 + tslib: ^2.4.0 + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10376dbf1b64a3659dfa01d63bdafbb8addac829c0e772fc4596df4b46f249bee179692cc3f06b1157bdc3dccfe3a46caf5499786cce203eb0f7e124c88a5648 + languageName: node + linkType: hard + "@graphql-tools/optimize@npm:^1.3.0": version: 1.4.0 resolution: "@graphql-tools/optimize@npm:1.4.0" @@ -4668,6 +4680,20 @@ __metadata: languageName: node linkType: hard +"@graphql-tools/schema@npm:^10.0.0": + version: 10.0.0 + resolution: "@graphql-tools/schema@npm:10.0.0" + dependencies: + "@graphql-tools/merge": ^9.0.0 + "@graphql-tools/utils": ^10.0.0 + tslib: ^2.4.0 + value-or-promise: ^1.0.12 + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: b746c69cefb3b89fad13d56f0abb9e764efe1569836ea9ae5e5c510a6f0bce6e08f324b28aebcb5b2c11ba2ea1c308f18c204e322a188e254e2c7e426d3ccecb + languageName: node + linkType: hard + "@graphql-tools/schema@npm:^9.0.0, @graphql-tools/schema@npm:^9.0.18": version: 9.0.19 resolution: "@graphql-tools/schema@npm:9.0.19" @@ -4682,6 +4708,19 @@ __metadata: languageName: node linkType: hard +"@graphql-tools/utils@npm:^10.0.0": + version: 10.0.6 + resolution: "@graphql-tools/utils@npm:10.0.6" + dependencies: + "@graphql-typed-document-node/core": ^3.1.1 + dset: ^3.1.2 + tslib: ^2.4.0 + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 85fb8faa73bd548e0dafe1d52710f246c0cacecf0f315488e7530fd3474f9642fc1cb75bd83965de1933cefc5aa5d2e579e4fd703d9113c8d95c0a67f0f401d2 + languageName: node + linkType: hard + "@graphql-tools/utils@npm:^8.8.0": version: 8.13.1 resolution: "@graphql-tools/utils@npm:8.13.1" @@ -6672,6 +6711,8 @@ __metadata: version: 0.0.0-use.local resolution: "@medusajs/modules-sdk@workspace:packages/modules-sdk" dependencies: + "@graphql-tools/merge": ^9.0.0 + "@graphql-tools/schema": ^10.0.0 "@medusajs/orchestration": ^0.4.1 "@medusajs/types": ^1.11.2 "@medusajs/utils": ^1.10.2