feat: add column introspection api (#13183)

This is part of a set of stacked PRs to add support for view configurations, which will allow users to customize the columns shown in admin tables.

The functionality in this PR is behind the view_configuration feature flag.

**What**
- Adds an API to introspect the remote query schema and extract columns that users can add to their table views.

**Notes**
- This is a brute forced approach to get the functionality in place and I expect it to evolve to something more elegant over time. Some ideas for things we might want to consider:
  - Compute the entity columns during build time and store as static data the API can serve quickly.
  - Offer developers more control over how their data can be exposed in this API with additional decorators in the DML.
This commit is contained in:
Sebastian Rindom
2025-08-15 17:35:01 +02:00
committed by GitHub
parent 83d2ce762c
commit d2cb9523e0
12 changed files with 1141 additions and 13 deletions

View File

@@ -0,0 +1,151 @@
export const ENTITY_MAPPINGS = {
orders: {
serviceName: "order",
graphqlType: "Order",
defaultVisibleFields: [
"display_id",
"created_at",
"payment_status",
"fulfillment_status",
"total",
"customer_display",
"country",
"sales_channel.name",
],
fieldFilters: {
// Fields that end with these suffixes will be excluded
excludeSuffixes: ["_link"],
// Fields that start with these prefixes will be excluded
excludePrefixes: ["raw_"],
// Specific field names to exclude
excludeFields: ["order_change"],
},
computedColumns: {
customer_display: {
name: "Customer",
computation_type: "customer_name",
required_fields: [
"customer.first_name",
"customer.last_name",
"customer.email",
],
optional_fields: ["customer.phone"],
default_visible: true,
},
shipping_address_display: {
name: "Shipping Address",
computation_type: "address_summary",
required_fields: [
"shipping_address.city",
"shipping_address.country_code",
],
optional_fields: [
"shipping_address.address_1",
"shipping_address.province",
"shipping_address.postal_code",
],
default_visible: false,
},
billing_address_display: {
name: "Billing Address",
computation_type: "address_summary",
required_fields: [
"billing_address.city",
"billing_address.country_code",
],
optional_fields: [
"billing_address.address_1",
"billing_address.province",
"billing_address.postal_code",
],
default_visible: false,
},
country: {
name: "Country",
computation_type: "country_code",
required_fields: ["shipping_address.country_code"],
optional_fields: [],
default_visible: true,
},
},
},
products: {
serviceName: "product",
graphqlType: "Product",
defaultVisibleFields: [
"title",
"handle",
"status",
"created_at",
"updated_at",
],
fieldFilters: {
excludeSuffixes: ["_link"],
excludePrefixes: ["raw_"],
excludeFields: [],
},
computedColumns: {},
},
customers: {
serviceName: "customer",
graphqlType: "Customer",
defaultVisibleFields: [
"email",
"first_name",
"last_name",
"created_at",
"updated_at",
],
fieldFilters: {
excludeSuffixes: ["_link"],
excludePrefixes: ["raw_"],
excludeFields: [],
},
computedColumns: {},
},
users: {
serviceName: "user",
graphqlType: "User",
defaultVisibleFields: [
"email",
"first_name",
"last_name",
"created_at",
"updated_at",
],
fieldFilters: {
excludeSuffixes: ["_link"],
excludePrefixes: ["raw_"],
excludeFields: [],
},
computedColumns: {},
},
regions: {
serviceName: "region",
graphqlType: "Region",
defaultVisibleFields: ["name", "currency_code", "created_at", "updated_at"],
fieldFilters: {
excludeSuffixes: ["_link"],
excludePrefixes: ["raw_"],
excludeFields: [],
},
computedColumns: {},
},
"sales-channels": {
serviceName: "salesChannel",
graphqlType: "SalesChannel",
defaultVisibleFields: [
"name",
"description",
"is_disabled",
"created_at",
"updated_at",
],
fieldFilters: {
excludeSuffixes: ["_link"],
excludePrefixes: ["raw_"],
excludeFields: [],
},
computedColumns: {},
},
}

View File

@@ -0,0 +1,571 @@
import {
GraphQLObjectType,
isEnumType,
isListType,
isNonNullType,
isScalarType,
makeExecutableSchema,
mergeTypeDefs,
graphqlSchemaToFields,
extractRelationsFromGQL,
cleanGraphQLSchema,
print,
} from "@medusajs/framework/utils"
import { HttpTypes } from "@medusajs/types"
import { MedusaModule } from "@medusajs/framework/modules-sdk"
import { ENTITY_MAPPINGS } from "./entity-mappings"
// Determine column category based on field characteristics
export const getColumnCategory = (
fieldName: string,
dataType: string,
semanticType?: string
): HttpTypes.AdminColumn["category"] => {
// Check semantic type first
if (semanticType === "timestamp") return "timestamp"
if (semanticType === "status") return "status"
// Check field name patterns
if (
fieldName.includes("_id") ||
fieldName === "id" ||
fieldName.includes("display_id") ||
fieldName === "code"
) {
return "identifier"
}
if (fieldName.includes("status") || fieldName === "state") {
return "status"
}
if (fieldName.includes("_at") || fieldName.includes("date")) {
return "timestamp"
}
if (
fieldName.includes("total") ||
fieldName.includes("amount") ||
fieldName.includes("price") ||
semanticType === "currency"
) {
return "metric"
}
if (dataType === "object" || fieldName.includes("_display")) {
return "relationship"
}
return "metadata"
}
// Helper function to format field name for display
export const formatFieldName = (field: string): string => {
return field
.split(/[._]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
// Helper function to get the underlying type from wrapped types (NonNull, List)
export const getUnderlyingType = (type: any): any => {
if (type.ofType) {
return getUnderlyingType(type.ofType)
}
return type
}
// Helper function to check if a field type is an array/list
export const isArrayField = (type: any): boolean => {
if (isListType(type)) {
return true
}
if (isNonNullType(type)) {
return isArrayField(type.ofType)
}
return false
}
// Helper function to check if a field is a single relationship (many-to-one, one-to-one)
export const isSingleRelationship = (type: any): boolean => {
// If it's a list, it's a one-to-many or many-to-many relationship
if (isArrayField(type)) {
return false
}
// Get the underlying type (removing NonNull wrappers)
const underlyingType = getUnderlyingType(type)
// Check if it's a GraphQL object type (relationship)
return underlyingType instanceof GraphQLObjectType
}
// Helper function to check if a field should be excluded based on filtering rules
export const shouldExcludeField = (
fieldName: string,
fieldFilters: any
): boolean => {
// Check if field matches any exclude suffixes
if (
fieldFilters.excludeSuffixes?.some((suffix: string) =>
fieldName.endsWith(suffix)
)
) {
return true
}
// Check if field matches any exclude prefixes
if (
fieldFilters.excludePrefixes?.some((prefix: string) =>
fieldName.startsWith(prefix)
)
) {
return true
}
// Check if field is in the exclude fields list
if (fieldFilters.excludeFields?.includes(fieldName)) {
return true
}
return false
}
// Helper function to determine data type and semantic type from GraphQL type
export const getTypeInfoFromGraphQLType = (
type: any,
fieldName: string
): {
data_type: HttpTypes.AdminColumn["data_type"]
semantic_type: string
context?: string
} => {
const underlyingType = type ? getUnderlyingType(type) : null
// Check field name patterns first for more specific types
if (fieldName.includes("_at") || fieldName.includes("date")) {
return {
data_type: "date",
semantic_type: "timestamp",
context: fieldName.includes("created")
? "creation"
: fieldName.includes("updated")
? "update"
: "generic",
}
} else if (
fieldName.includes("total") ||
fieldName.includes("amount") ||
fieldName.includes("price")
) {
return {
data_type: "currency",
semantic_type: "currency",
context: fieldName.includes("total") ? "total" : "amount",
}
} else if (fieldName.includes("count") || fieldName.includes("quantity")) {
return {
data_type: "number",
semantic_type: "count",
context: fieldName.includes("quantity") ? "quantity" : "count",
}
} else if (fieldName.includes("status")) {
return {
data_type: "enum",
semantic_type: "status",
context: fieldName.includes("payment")
? "payment"
: fieldName.includes("fulfillment")
? "fulfillment"
: "generic",
}
} else if (fieldName.includes("type") || fieldName.includes("is_")) {
return {
data_type: "enum",
semantic_type: "enum",
context: "generic",
}
} else if (fieldName === "metadata" || fieldName.includes("json")) {
return {
data_type: "object",
semantic_type: "object",
context: "metadata",
}
} else if (fieldName === "display_id") {
return {
data_type: "string",
semantic_type: "identifier",
context: "order",
}
} else if (fieldName === "email") {
return {
data_type: "string",
semantic_type: "email",
context: "contact",
}
}
// Then check GraphQL type
if (underlyingType && isScalarType(underlyingType)) {
switch (underlyingType.name) {
case "Int":
case "Float":
return {
data_type: "number",
semantic_type: "number",
context: "generic",
}
case "Boolean":
return {
data_type: "boolean",
semantic_type: "boolean",
context: "generic",
}
case "DateTime":
return {
data_type: "date",
semantic_type: "timestamp",
context: "generic",
}
case "JSON":
return {
data_type: "object",
semantic_type: "object",
context: "json",
}
default:
return {
data_type: "string",
semantic_type: "string",
context: "generic",
}
}
} else if (underlyingType && isEnumType(underlyingType)) {
return {
data_type: "enum",
semantic_type: "enum",
context: "generic",
}
} else {
return {
data_type: "object",
semantic_type: "object",
context: "relationship",
}
}
}
export const DEFAULT_COLUMN_ORDERS: Record<string, number> = {
display_id: 100,
created_at: 200,
customer_display: 300,
"sales_channel.name": 400,
fulfillment_status: 500,
payment_status: 600,
total: 700,
country: 800,
}
/**
* Generates columns for a given entity by introspecting the GraphQL schema
* @param entity - The entity name to generate columns for
* @param entityMapping - The entity mapping configuration
* @returns Array of columns or null if generation fails
*/
export const generateEntityColumns = (
entity: string,
entityMapping: typeof ENTITY_MAPPINGS[keyof typeof ENTITY_MAPPINGS]
): HttpTypes.AdminColumn[] | null => {
const joinerConfigs = MedusaModule.getAllJoinerConfigs()
const schemaFragments: string[] = []
let hasEntityType = false
for (const config of joinerConfigs) {
if (config.schema) {
schemaFragments.push(config.schema)
if (config.schema.includes(`type ${entityMapping.graphqlType} {`)) {
hasEntityType = true
}
}
}
if (!hasEntityType || schemaFragments.length === 0) {
return null
}
const scalarDefinitions = `
scalar DateTime
scalar JSON
`
const allSchemas = [scalarDefinitions, ...schemaFragments]
const mergedSchemaAST = mergeTypeDefs(allSchemas)
const mergedSchemaString = print(mergedSchemaAST)
const { schema: cleanedSchemaString } =
cleanGraphQLSchema(mergedSchemaString)
const schema = makeExecutableSchema({
typeDefs: cleanedSchemaString,
resolvers: {}, // Empty resolvers since we only need the schema for introspection
})
const schemaTypeMap = schema.getTypeMap()
const entityType = schemaTypeMap[
entityMapping.graphqlType
] as GraphQLObjectType
const allDirectFields = graphqlSchemaToFields(
schemaTypeMap,
entityMapping.graphqlType,
[]
)
// Filter out problematic fields
const directFields = allDirectFields.filter((fieldName) => {
const field = entityType?.getFields()[fieldName]
if (!field) return true
const isArray = isArrayField(field.type)
if (isArray) {
return false
}
if (shouldExcludeField(fieldName, entityMapping.fieldFilters)) {
return false
}
return true
})
if (entity === "orders" && !directFields.includes("display_id")) {
directFields.unshift("display_id")
}
const relationMap = extractRelationsFromGQL(
new Map(Object.entries(schemaTypeMap))
)
const allEntityRelations = relationMap.get(entityMapping.graphqlType)
const filteredUtilityRelations = new Map<string, string>()
if (allEntityRelations && entityType) {
const fields = entityType.getFields()
for (const [fieldName, relatedTypeName] of allEntityRelations) {
const field = fields[fieldName]
if (shouldExcludeField(fieldName, entityMapping.fieldFilters)) {
continue
}
if (field && isSingleRelationship(field.type)) {
filteredUtilityRelations.set(fieldName, relatedTypeName)
}
}
}
const manualRelations = new Map<string, string>()
if (entityType) {
const fields = entityType.getFields()
Object.entries(fields).forEach(([fieldName, field]) => {
if (shouldExcludeField(fieldName, entityMapping.fieldFilters)) {
return
}
if (isSingleRelationship(field.type)) {
const fieldType = getUnderlyingType(field.type)
manualRelations.set(fieldName, fieldType.name)
}
})
}
const finalRelations =
filteredUtilityRelations.size > 0
? filteredUtilityRelations
: manualRelations
if (directFields.length === 0) {
return null
}
const directColumns = directFields.map((fieldName) => {
const displayName = formatFieldName(fieldName)
const type = schemaTypeMap[
entityMapping.graphqlType
] as GraphQLObjectType
const fieldDef = type?.getFields()?.[fieldName]
const typeInfo = fieldDef
? getTypeInfoFromGraphQLType(fieldDef.type, fieldName)
: getTypeInfoFromGraphQLType(null, fieldName)
const sortable =
!fieldName.includes("metadata") && typeInfo.data_type !== "object"
const isDefaultField =
entityMapping.defaultVisibleFields.includes(fieldName)
const defaultOrder =
DEFAULT_COLUMN_ORDERS[fieldName] || (isDefaultField ? 500 : 850)
const category = getColumnCategory(
fieldName,
typeInfo.data_type,
typeInfo.semantic_type
)
return {
id: fieldName,
name: displayName,
description: `${displayName} field`,
field: fieldName,
sortable,
hideable: true,
default_visible:
entityMapping.defaultVisibleFields.includes(fieldName),
data_type: typeInfo.data_type,
semantic_type: typeInfo.semantic_type,
context: typeInfo.context,
default_order: defaultOrder,
category,
}
})
const relationshipColumns: HttpTypes.AdminColumn[] = []
if (finalRelations.size > 0) {
for (const [relationName, relatedTypeName] of finalRelations) {
const allRelatedFields = graphqlSchemaToFields(
schemaTypeMap,
relatedTypeName,
[]
)
// Filter out problematic fields from related type
const relatedType = schemaTypeMap[
relatedTypeName
] as GraphQLObjectType
const relatedFields = allRelatedFields.filter((fieldName) => {
const field = relatedType?.getFields()[fieldName]
if (!field) return true
const isArray = isArrayField(field.type)
if (isArray) {
return false
}
// Apply entity-specific field filters to related fields as well
if (shouldExcludeField(fieldName, entityMapping.fieldFilters)) {
return false
}
return true
})
const limitedFields = relatedFields.slice(0, 10)
limitedFields.forEach((fieldName) => {
const fieldPath = `${relationName}.${fieldName}`
const displayName = `${formatFieldName(
relationName
)} ${formatFieldName(fieldName)}`
const relatedType = schemaTypeMap[
relatedTypeName
] as GraphQLObjectType
const fieldDef = relatedType?.getFields()?.[fieldName]
const typeInfo = fieldDef
? getTypeInfoFromGraphQLType(fieldDef.type, fieldName)
: {
data_type: "string" as const,
semantic_type: "string",
context: "generic",
}
const sortable = fieldPath.includes(".")
? false
: ["name", "title", "email", "handle"].includes(fieldName)
const isDefaultVisible =
entityMapping.defaultVisibleFields.includes(fieldPath)
// Get default order and category
// If field is not in default visible fields, place it after country (850)
const isDefaultField =
entityMapping.defaultVisibleFields.includes(fieldPath)
const defaultOrder =
DEFAULT_COLUMN_ORDERS[fieldPath] || (isDefaultField ? 700 : 850)
const category = getColumnCategory(
fieldPath,
typeInfo.data_type,
typeInfo.semantic_type
)
relationshipColumns.push({
id: fieldPath,
name: displayName,
description: `${displayName} from related ${relatedTypeName}`,
field: fieldPath,
sortable,
hideable: true,
default_visible: isDefaultVisible,
data_type: typeInfo.data_type,
semantic_type: typeInfo.semantic_type,
context: typeInfo.context,
relationship: {
entity: relatedTypeName,
field: fieldName,
},
default_order: defaultOrder,
category,
})
})
}
}
// Generate computed columns
const computedColumns: HttpTypes.AdminColumn[] = []
if (entityMapping.computedColumns) {
for (const [columnId, columnConfig] of Object.entries(
entityMapping.computedColumns
)) {
// Get default order and category for computed columns
// If field is not in default visible fields, place it after country (850)
const isDefaultField =
entityMapping.defaultVisibleFields.includes(columnId)
const defaultOrder =
DEFAULT_COLUMN_ORDERS[columnId] || (isDefaultField ? 600 : 850)
const category = getColumnCategory(columnId, "string", "computed")
computedColumns.push({
id: columnId,
name: columnConfig.name,
description: `${columnConfig.name} (computed)`,
field: columnId,
sortable: false, // Computed columns can't be sorted server-side
hideable: true,
default_visible:
entityMapping.defaultVisibleFields.includes(columnId),
data_type: "string", // Computed columns typically output strings
semantic_type: "computed",
context: "display",
computed: {
type: columnConfig.computation_type,
required_fields: columnConfig.required_fields,
optional_fields: columnConfig.optional_fields || [],
},
default_order: defaultOrder,
category,
})
}
}
const allColumns = [
...directColumns,
...relationshipColumns,
...computedColumns,
]
return allColumns
}

View File

@@ -0,0 +1,18 @@
import { validateAndTransformQuery } from "@medusajs/framework"
import { MiddlewareRoute } from "@medusajs/framework/http"
import { AdminGetColumnsParams } from "./validators"
import { ensureViewConfigurationsEnabled } from "../configurations/middleware"
export const columnRoutesMiddlewares: MiddlewareRoute[] = [
// Apply feature flag check to all column routes
{
method: ["GET", "POST"],
matcher: "/admin/views/*/columns",
middlewares: [ensureViewConfigurationsEnabled],
},
{
method: ["GET"],
matcher: "/admin/views/:entity/columns",
middlewares: [validateAndTransformQuery(AdminGetColumnsParams, {})],
},
]

View File

@@ -0,0 +1,42 @@
import { HttpTypes } from "@medusajs/framework/types"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import {
MedusaError,
} from "@medusajs/framework/utils"
import { generateEntityColumns } from "./helpers"
import { ENTITY_MAPPINGS } from "./entity-mappings"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse<HttpTypes.AdminViewsEntityColumnsResponse>
) => {
const entity = req.params.entity
const entityMapping = ENTITY_MAPPINGS[entity as keyof typeof ENTITY_MAPPINGS]
if (!entityMapping) {
return res.status(400).json({
message: `Unsupported entity: ${entity}`,
type: "invalid_data",
} as any)
}
try {
const columns = generateEntityColumns(entity, entityMapping)
if (columns) {
return res.json({
columns,
})
}
} catch (schemaError) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
`Schema introspection failed for entity: ${entity}. Please check if the entity exists in the schema.`
)
}
return res.sendStatus(500)
}

View File

@@ -0,0 +1,5 @@
import { z } from "zod"
import { createSelectParams } from "../../../../utils/validators"
export type AdminGetColumnsParamsType = z.infer<typeof AdminGetColumnsParams>
export const AdminGetColumnsParams = createSelectParams()

View File

@@ -42,6 +42,7 @@ import { adminTaxProviderRoutesMiddlewares } from "./admin/tax-providers/middlew
import { adminUploadRoutesMiddlewares } from "./admin/uploads/middlewares"
import { adminUserRoutesMiddlewares } from "./admin/users/middlewares"
import { viewConfigurationRoutesMiddlewares } from "./admin/views/[entity]/configurations/middlewares"
import { columnRoutesMiddlewares } from "./admin/views/[entity]/columns/middlewares"
import { adminWorkflowsExecutionsMiddlewares } from "./admin/workflows-executions/middlewares"
import { authRoutesMiddlewares } from "./auth/middlewares"
@@ -128,4 +129,5 @@ export default defineMiddlewares([
...adminOrderEditRoutesMiddlewares,
...adminPaymentCollectionsMiddlewares,
...viewConfigurationRoutesMiddlewares,
...columnRoutesMiddlewares,
])