Files
2026-03-07 11:07:45 -03:00

24 KiB

Querying Data in Medusa

Medusa's Query API (query.graph()) is the primary way to retrieve data, especially across modules. It provides a flexible, performant way to query entities with relations and filters.

Contents

When to Use Query vs Module Services

⚠️ USE QUERY FOR:

  • Retrieving data across modules (products with linked brands, orders with customers)
  • Reading data with linked entities
  • Complex queries with multiple relations
  • Storefront and admin data retrieval

⚠️ USE MODULE SERVICES FOR:

  • Retrieving data within a single module (products with variants - same module)
  • Using listAndCount for pagination within one module
  • Mutations (always use module services or workflows)

Examples:

// ✅ GOOD: Query for cross-module data
const { data } = await query.graph({
  entity: "product",
  fields: ["id", "title", "brand.*"], // brand is in different module
})

// ✅ GOOD: Module service for single module
const [products, count] = await productService.listAndCountProducts(
  { status: "active" },
  { take: 10, skip: 0 }
)

Basic Query Structure

const query = req.scope.resolve("query")

const { data } = await query.graph({
  entity: "entity_name",     // The entity to query
  fields: ["id", "name"],    // Fields to retrieve
  filters: { status: "active" }, // Filter conditions
  pagination: {              // Optional pagination
    take: 10,
    skip: 0,
  },
})

In Workflows vs Outside Workflows

Outside Workflows (API Routes, Subscribers, Scheduled Jobs)

// In API routes
const query = req.scope.resolve("query")

const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title"],
})

// In subscribers/scheduled jobs
const query = container.resolve("query")

const { data: customers } = await query.graph({
  entity: "customer",
  fields: ["id", "email"],
})

In Workflows

Use useQueryGraphStep within workflow composition functions:

import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "@medusajs/medusa/core-flows"

const myWorkflow = createWorkflow(
  "my-workflow",
  function (input) {
    const { data: products } = useQueryGraphStep({
      entity: "product",
      fields: ["id", "title"],
      filters: {
        id: input.product_id,
      },
    })

    return new WorkflowResponse({ products })
  }
)

Field Selection

Basic Fields

const { data } = await query.graph({
  entity: "product",
  fields: ["id", "title", "description"],
})

Nested Relations

Use dot notation to include related entities:

const { data } = await query.graph({
  entity: "product",
  fields: [
    "id",
    "title",
    "variants.*", // All fields from variants
    "variants.sku", // Specific variant field
    "category.id",
    "category.name",
  ],
})

Performance Tip

⚠️ IMPORTANT: Only retrieve fields and relations you'll actually use. Avoid using * to select all fields or retrieving all fields of a relation unnecessarily.

// ❌ BAD: Retrieves all fields (inefficient)
fields: ["*"]

// ❌ BAD: Retrieves all product fields (might be many)
fields: ["product.*"]

// ✅ GOOD: Only retrieves needed fields
fields: ["id", "title", "product.id", "product.title"]

Filtering

Exact Match

filters: {
  email: "user@example.com"
}

Multiple Values (IN operator)

filters: {
  id: ["id1", "id2", "id3"]
}

Range Queries

filters: {
  created_at: {
    $gte: startDate, // Greater than or equal
    $lte: endDate,   // Less than or equal
  }
}

Text Search (LIKE)

filters: {
  name: {
    $like: "%search%" // Contains "search"
  }
}

// Starts with
filters: {
  name: {
    $like: "search%"
  }
}

// Ends with
filters: {
  name: {
    $like: "%search"
  }
}

Not Equal

filters: {
  status: {
    $ne: "deleted"
  }
}

Multiple Conditions

filters: {
  status: "active",
  created_at: {
    $gte: new Date("2024-01-01"),
  },
  price: {
    $gte: 10,
    $lte: 100,
  },
}

Filtering Nested Relations (Same Module)

To filter by fields in nested relations within the same module, use object notation:

// Product and ProductVariant are in the same module (Product Module)
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title", "variants.*"],
  filters: {
    variants: {
      sku: "ABC1234" // ✅ Works: variants are in same module as product
    }
  }
})

Important Filtering Limitation

⚠️ CRITICAL: With query.graph(), you CANNOT filter by fields from linked data models in different modules. The query.graph() method only supports filters on data models within the same module.

What This Means

  • Same Module ( Can filter with query.graph()): Product and ProductVariant, Order and LineItem, Cart and CartItem
  • Different Modules ( Cannot filter with query.graph()): Product and Brand (custom), Product and Customer, Review and Product
  • Different Modules ( Can filter with query.index()): Any linked modules when using the Index Module

Example: Cannot Filter Products by Linked Brand with query.graph()

// ❌ THIS DOES NOT WORK with query.graph()
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title", "brand.*"],
  filters: {
    "brand.name": "Nike" // ❌ Cannot filter by linked module field
  }
})

// ❌ THIS ALSO DOES NOT WORK with query.graph()
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title", "brand.*"],
  filters: {
    brand: {
      name: "Nike" // ❌ Still doesn't work - brand is in different module
    }
  }
})

BEST APPROACH: Use the Index Module to filter across linked modules efficiently at the database level:

// ✅ CORRECT: Use query.index() to filter products by linked brand
const { data: products } = await query.index({
  entity: "product",
  fields: ["*", "brand.*"],
  filters: {
    brand: {
      name: "Nike" // ✅ Works with Index Module!
    }
  }
})

Why this is best:

  • Database-level filtering (most efficient)
  • Supports pagination properly
  • Only retrieves the data you need
  • Designed specifically for cross-module filtering

Requirements:

  • Index Module must be installed and configured
  • Link must have filterable properties defined
  • See Querying Linked Data section for setup details

Solution 2: Query from Other Side

GOOD ALTERNATIVE: Query the linked module and filter on it directly using query.graph():

// ✅ CORRECT: Query brands and get their products
const { data: brands } = await query.graph({
  entity: "brand",
  fields: ["id", "name", "products.*"],
  filters: {
    name: "Nike" // ✅ Filter on brand directly
  }
})

// Access Nike products
const nikeProducts = brands[0]?.products || []

Use this when:

  • You don't have the Index Module set up
  • The "other side" of the link makes sense as the primary entity
  • You need a quick solution without additional setup

Solution 3: Filter After Query (Least Efficient)

⚠️ LAST RESORT: Query all data with query.graph(), then filter in JavaScript:

// Get all products with brands
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title", "brand.*"],
})

// Filter in JavaScript after query
const nikeProducts = products.filter(p => p.brand?.name === "Nike")

Only use this when:

  • Dataset is very small (< 100 records)
  • Index Module is not available
  • Querying from the other side doesn't make sense
  • You need a temporary solution

Avoid because:

  • Fetches unnecessary data from database
  • Inefficient for large datasets
  • No pagination support at database level
  • Uses more memory and network bandwidth

More Examples

Example: Approved Reviews for a Specific Product

When you need to filter linked data by its own properties, you have multiple options:

// ❌ WRONG: Cannot filter linked reviews from product query with query.graph()
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "reviews.*"],
  filters: {
    id: productId,
    reviews: {
      status: "approved" // ❌ Doesn't work - reviews is linked module
    }
  }
})

// ❌ ALSO WRONG: Filtering in JavaScript is inefficient
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "reviews.*"],
  filters: { id: productId }
})
const approvedReviews = products[0].reviews.filter(r => r.status === "approved") // ❌ Client-side filter

// ✅ OPTION 1 (BEST): Use Index Module to filter cross-module
const { data: products } = await query.index({
  entity: "product",
  fields: ["*", "reviews.*"],
  filters: {
    id: productId,
    reviews: {
      status: "approved" // ✅ Works with Index Module!
    }
  }
})

// ✅ OPTION 2 (GOOD): Query reviews directly with filters
const { data: reviews } = await query.graph({
  entity: "review",
  fields: ["id", "rating", "comment", "product.*"],
  filters: {
    product_id: productId, // Filter by product
    status: "approved"     // Filter by review status - both in same query!
  }
})

Why Option 1 (Index Module) is best:

  • Database-level filtering across modules
  • Returns data in the structure you expect (product with reviews)
  • Supports pagination properly
  • Only retrieves the data you need

Why Option 2 (query from other side) is good:

  • No Index Module setup required
  • Still uses database filtering
  • Works well when the "other side" is the logical primary entity

Example: Reviews for Active Products (Cross-Module)

// ❌ WRONG: Cannot filter by linked module with query.graph()
const { data } = await query.graph({
  entity: "review",
  fields: ["id", "rating", "product.*"],
  filters: {
    product: {
      status: "active" // Doesn't work - product is linked module
    }
  }
})

// ✅ OPTION 1 (BEST): Use Index Module
const { data: reviews } = await query.index({
  entity: "review",
  fields: ["*", "product.*"],
  filters: {
    product: {
      status: "active" // ✅ Works with Index Module!
    }
  }
})

// ✅ OPTION 2 (GOOD): Query from the other side
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title", "reviews.*"],
  filters: { status: "active" }
})

// Flatten reviews if needed
const reviews = products.flatMap(p => p.reviews)

Example: Products with Variants (Same Module - Works!)

// ✅ CORRECT: Product and variants are in same module (Product Module)
// Use query.graph() - no need for Index Module
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title", "variants.*"],
  filters: {
    variants: {
      inventory_quantity: {
        $gte: 10 // ✅ Works: both in Product Module
      }
    }
  }
})

Pagination

Basic Pagination

const { data, metadata } = await query.graph({
  entity: "product",
  fields: ["id", "title"],
  pagination: {
    skip: 0,   // Offset
    take: 10,  // Limit
  },
})

// metadata.count contains total count
console.log(`Total: ${metadata.count}`)

With Ordering

const { data } = await query.graph({
  entity: "product",
  fields: ["id", "title", "created_at"],
  pagination: {
    skip: 0,
    take: 10,
    order: {
      created_at: "DESC", // Newest first
    },
  },
})

Multiple Order Fields

pagination: {
  order: {
    status: "ASC",
    created_at: "DESC",
  }
}

Querying Linked Data

When entities are linked via module links, you have two options depending on your filtering needs:

Option 1: query.graph() - Retrieve Linked Data Without Cross-Module Filters

Use query.graph() when:

  • Retrieving linked data without filtering by linked module properties
  • Filtering only by properties in the primary entity's module
  • You want to include related data in the response

Limitations:

  • CANNOT filter by properties of linked modules (data models in separate modules)
  • CAN filter by properties of relations in the same module (e.g., product.variants)
// ✅ WORKS: Get products with their linked brands (no cross-module filtering)
const { data: products } = await query.graph({
  entity: "product",
  fields: [
    "id",
    "title",
    "brand.*", // All brand fields
  ],
  filters: {
    id: "prod_123", // ✅ Filter by product property (same module)
  },
})

// Access linked data
console.log(products[0].brand.name)

// ✅ WORKS: Filter by same-module relation (product and variants are in Product Module)
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title", "variants.*"],
  filters: {
    variants: {
      sku: "ABC1234" // ✅ Works: variants are in same module as product
    }
  }
})

// ❌ DOES NOT WORK: Cannot filter products by linked brand name
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title", "brand.*"],
  filters: {
    brand: {
      name: "Nike" // ❌ Fails: brand is in a different module
    }
  }
})

Reverse Query (From Link to Original):

// Get brands with their linked products
const { data: brands } = await query.graph({
  entity: "brand",
  fields: [
    "id",
    "name",
    "products.*", // All linked products
  ],
})

// Access linked products
brands[0].products.forEach(product => {
  console.log(product.title)
})

Option 2: query.index() - Filter Across Linked Modules (Index Module)

Use query.index() when:

  • You need to filter data by properties of linked modules (separate modules with module links)
  • Filtering by custom data model properties linked to Commerce Module entities
  • Complex cross-module queries requiring efficient database-level filtering

Key Distinction:

  • Same module relations (e.g., Product → ProductVariant): Use query.graph()
  • Different module links (e.g., Product → Brand, Product → Review): Use query.index()

When to Use query.index()

The Index Module solves the fundamental limitation of query.graph(): you cannot filter one module's data by another module's linked properties using query.graph().

Examples of when you need query.index():

  • Filter products by brand name (Product Module → Brand Module)
  • Filter products by review ratings (Product Module → Review Module)
  • Filter customers by custom loyalty tier (Customer Module → Loyalty Module)
  • Any scenario where you need to filter by properties of a linked data model in a different module

Setup Requirements

Before using query.index(), ensure the Index Module is configured:

  1. Install the Index Module:

    npm install @medusajs/index
    
  2. Add to medusa-config.ts:

    module.exports = defineConfig({
      modules: [
        {
          resolve: "@medusajs/index",
        },
      ],
    })
    
  3. Enable the feature flag in .env:

    MEDUSA_FF_INDEX_ENGINE=true
    
  4. Run migrations:

    npx medusa db:migrate
    
  5. Mark linked properties as filterable in your link definition:

    // src/links/product-brand.ts
    defineLink(
      { linkable: ProductModule.linkable.product, isList: true },
      { linkable: BrandModule.linkable.brand, filterable: ["id", "name"] }
    )
    

    The filterable property marks which fields can be queried across modules.

  6. Start the application to trigger data ingestion into the Index Module.

Using query.index()

const query = req.scope.resolve("query")

// ✅ CORRECT: Filter products by linked brand name using Index Module
const { data: products } = await query.index({
  entity: "product",
  fields: ["*", "brand.*"],
  filters: {
    brand: {
      name: "Nike", // ✅ Works with Index Module!
    },
  },
})

// ✅ CORRECT: Filter products by review ratings
const { data: products } = await query.index({
  entity: "product",
  fields: ["id", "title", "reviews.*"],
  filters: {
    reviews: {
      rating: {
        $gte: 4, // Products with reviews rated 4 or higher
      },
    },
  },
})

query.index() Features

Pagination:

const { data: products } = await query.index({
  entity: "product",
  fields: ["*", "brand.*"],
  filters: {
    brand: { name: "Nike" },
  },
  pagination: {
    take: 20,
    skip: 0,
  },
})

Advanced Filters:

const { data: products } = await query.index({
  entity: "product",
  fields: ["*", "brand.*"],
  filters: {
    brand: {
      name: {
        $like: "%Acme%", // LIKE operator
      },
    },
    status: {
      $ne: "deleted", // Not equal
    },
  },
})

query.graph() vs query.index() Decision Tree

Need to filter by linked module properties?
├─ No → Use query.graph()
│   └─ Faster, simpler, works for most queries
│
└─ Yes → Are the entities in the same module or different modules?
    ├─ Same module (e.g., product.variants) → Use query.graph()
    │   └─ Example: Product and ProductVariant both in Product Module
    │
    └─ Different modules (e.g., product → brand) → Use query.index()
        └─ Example: Product (Product Module) → Brand (Custom Module)
        └─ Requires Index Module setup and filterable properties

Important Notes

  • Performance: The Index Module pre-ingests data on application startup, enabling efficient cross-module filtering
  • Data Freshness: Data is synced automatically, but there may be a brief delay after mutations
  • Fallback: If you don't need filtering, query.graph() is sufficient and more straightforward
  • Module Relations: Always use query.graph() for same-module relations (product → variants, order → line items)

Validation with throwIfKeyNotFound

Use throwIfKeyNotFound to validate that a record exists before performing operations:

// Outside workflows
const query = req.scope.resolve("query")

const { data } = await query.graph({
  entity: "product",
  fields: ["id", "title"],
  filters: {
    id: productId,
  },
}, {
  throwIfKeyNotFound: true, // Throws if product doesn't exist
})

// If we get here, product exists
const product = data[0]
// In workflows
const { data: products } = useQueryGraphStep({
  entity: "product",
  fields: ["id", "title"],
  filters: {
    id: input.product_id,
  },
  options: {
    throwIfKeyNotFound: true, // Throws if product doesn't exist
  },
})

When to use:

  • Before updating or deleting a record
  • When the record MUST exist for the operation to continue
  • To avoid manual existence checks
// ❌ BAD: Manual check
const { data } = await query.graph({ /* ... */ })
if (!data || data.length === 0) {
  throw new MedusaError(MedusaError.Types.NOT_FOUND, "Product not found")
}

// ✅ GOOD: Let query handle it
const { data } = await query.graph(
  { /* ... */ },
  { throwIfKeyNotFound: true }
)

Performance Best Practices

1. Only Query What You Need

⚠️ CRITICAL: Always specify only the fields you'll use. Avoid using * or querying unnecessary relations.

// ❌ BAD: Retrieves everything (slow, wasteful)
fields: ["*"]

// ✅ GOOD: Only needed fields (fast)
fields: ["id", "title", "price"]

2. Limit Relation Depth

There's no hard limit on relation depth, but deeper queries are slower. Only include relations you'll actually use.

// ❌ BAD: Unnecessary depth
fields: [
  "id",
  "title",
  "variants.*",
  "variants.product.*", // Circular, unnecessary
  "variants.prices.*",
  "variants.prices.currency.*", // Probably don't need all currency fields
]

// ✅ GOOD: Appropriate depth
fields: [
  "id",
  "title",
  "variants.id",
  "variants.sku",
  "variants.prices.amount",
  "variants.prices.currency_code",
]

3. Use Pagination for Large Result Sets

// ✅ GOOD: Paginated query
const { data, metadata } = await query.graph({
  entity: "product",
  fields: ["id", "title"],
  pagination: {
    take: 50, // Don't retrieve thousands of records at once
    skip: 0,
  },
})

4. Filter Early

Apply filters to reduce the data set before retrieving fields and relations:

// ✅ GOOD: Filters reduce result set first
const { data } = await query.graph({
  entity: "product",
  fields: ["id", "title", "variants.*"],
  filters: {
    status: "published",
    created_at: {
      $gte: lastWeek,
    },
  },
})

5. Use Specific Queries for Different Use Cases

// ✅ For listings (minimal fields)
const { data: listings } = await query.graph({
  entity: "product",
  fields: ["id", "title", "thumbnail", "price"],
})

// ✅ For detail pages (more fields)
const { data: details } = await query.graph({
  entity: "product",
  fields: [
    "id",
    "title",
    "description",
    "thumbnail",
    "images.*",
    "variants.*",
    "variants.prices.*",
  ],
  filters: { id: productId },
})

Common Patterns

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve("query")
  const { q } = req.validatedQuery

  const filters: any = {}
  if (q) {
    filters.title = { $like: `%${q}%` }
  }

  const { data: products } = await query.graph({
    entity: "product",
    fields: ["id", "title", "thumbnail"],
    filters,
    ...req.queryConfig, // Uses request query config
  })

  return res.json({ products })
}

Pattern: Retrieve with Validation

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve("query")
  const { id } = req.params

  // Throws 404 if product doesn't exist
  const { data } = await query.graph({
    entity: "product",
    fields: ["id", "title", "description", "variants.*"],
    filters: { id },
  }, {
    throwIfKeyNotFound: true,
  })

  return res.json({ product: data[0] })
}

Pattern: Query with Relations and Filters

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve("query")
  const { category_id } = req.validatedQuery

  const { data: products } = await query.graph({
    entity: "product",
    fields: [
      "id",
      "title",
      "thumbnail",
      "variants.id",
      "variants.prices.amount",
      "category.name",
    ],
    filters: {
      category_id,
      status: "published",
    },
    pagination: {
      take: 20,
      skip: 0,
    },
  })

  return res.json({ products })
}

Pattern: Count Records

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve("query")

  const { data, metadata } = await query.graph({
    entity: "product",
    fields: ["id"], // Minimal fields for counting
    filters: {
      status: "published",
    },
  })

  return res.json({
    count: metadata.count,
  })
}

Pattern: Recent Items

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve("query")

  const { data: recentProducts } = await query.graph({
    entity: "product",
    fields: ["id", "title", "created_at"],
    pagination: {
      take: 10,
      skip: 0,
      order: {
        created_at: "DESC", // Newest first
      },
    },
  })

  return res.json({ products: recentProducts })
}