Query

In this chapter, you'll learn about Query and how to use it to fetch data from modules.

What is Query?#

Query fetches data across modules. It's a tool that wraps the module services of the application and the module links, and it lets you retrieve the records of a data model together with the records linked to it, even when they belong to other modules.

For example, with Query you retrieve a post of the Blog Module with the products of the Product Module that it's linked to, in one call. Query lists the posts, finds their links, and then retrieves the products.

The API routes of the Admin and Store APIs use Query to retrieve their data.


Query Example#

Query is registered in the container under the key common.ContainerRegistrationKeys.QUERY. Its type is *query.Query, from the query package of core:

src/queries/query.go
// Resolver is what routes (req.Scope), workflow steps and scripts resolve
// from: the container.
type Resolver interface {
	Resolve(key string) any
}

func queryOf(c Resolver) *query.Query {
	return c.Resolve(common.ContainerRegistrationKeys.QUERY).(*query.Query)
}

These are the imports of the examples of this chapter:

src/queries/query.go
import (
	"context"

	"github.com/hitkey-io/medusago/packages/core/query/src/joiner"
	"github.com/hitkey-io/medusago/packages/core/query/src/query"
	"github.com/hitkey-io/medusago/packages/core/utils/src/caching"
	"github.com/hitkey-io/medusago/packages/core/utils/src/common"
	"github.com/hitkey-io/medusago/packages/core/utils/src/dal"
)

To retrieve data, call its Graph method. It takes a GraphInput, which describes the data to retrieve, and a joiner.Options value, for options such as the locale:

src/queries/query.go
// PostTitles returns the ID and title of every post.
func PostTitles(ctx context.Context, c Resolver) ([]string, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity: "post",
		Fields: []string{"id", "title"},
	}, joiner.Options{})
	if err != nil {
		return nil, err
	}

	titles := make([]string, 0, len(res.Data))
	for _, row := range res.Data {
		post := row.(*dal.Record)
		title, _ := post.Get("title")
		titles = append(titles, title.(string))
	}
	return titles, nil
}

The GraphInput has these fields:

| Field | Description | | --- | --- | | Entity | The name of the data model to retrieve, in snake case, such as post, product, or sales_channel. It's the alias of the data model. | | Fields | The properties to retrieve, as a list. | | Filters | The filters on the records, as a *dal.Record. | | Pagination | The pagination and the order of the records, as a *dal.Record. | | WithDeleted | Includes the soft-deleted records when true. | | Context | The context to pass to modules that use one, such as pricing. |

Graph returns a *GraphResult:

  • Data is a []any of the records. Each one is a *dal.Record, an ordered map of the retrieved properties.
  • Metadata holds the count, skip, and take of the query when you paginate. It's nil otherwise.
Note: 

The examples of this chapter use the small rec function that builds a *dal.Record from a list of keys and values. You define it in your project:

src/queries/query.go
// rec builds a *dal.Record, an ordered map that encodes to JSON with its
// keys in insertion order.
func rec(kv ...any) *dal.Record {
	r := dal.NewRecord()
	for i := 0; i+1 < len(kv); i += 2 {
		r.Set(kv[i].(string), kv[i+1])
	}
	return r
}

Query Usage in Workflows#

In a workflow, use the UseQueryGraphStep step from core-flows/src/common/steps. It takes a GraphInput, and returns the result of the query:

src/workflows/posts.go
// PostsInput is the input of PostsWorkflow.
type PostsInput struct {
	Status string
}

// PostsWorkflow retrieves posts with the useQueryGraph step.
var PostsWorkflow = composer.CreateWorkflow("posts-workflow",
	func(e *composer.Execution, input PostsInput) (any, error) {
		result, err := commonsteps.UseQueryGraphStep.Run(e, commonsteps.UseQueryGraphStepInput{
			GraphInput: query.GraphInput{
				Entity:  "post",
				Fields:  []string{"id", "title", "products.*"},
				Filters: rec("status", input.Status),
			},
		})
		if err != nil {
			return nil, err
		}
		return result.Data, nil
	})

The Data of the result is the list of records. If you set the IsList field of the step's input to false, it's the first record.


Querying the Graph#

Query's name for the data that you can retrieve is the graph: the data models of the modules, and the links between them.

A data model is in the graph when it has an alias, which is the name you pass in Entity: every data model of a module has the snake case of its name and its plural, such as post and posts. The module links add the linked data models as properties of the data models that they link.


Select Specific Fields#

Pass the names of the properties to retrieve in the Fields list. To retrieve all the properties of a record, use *:

src/queries/query.go
// PostWithAllFields retrieves every field of one post.
func PostWithAllFields(ctx context.Context, c Resolver, id string) (*dal.Record, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity:  "post",
		Fields:  []string{"*"},
		Filters: rec("id", id),
	}, joiner.Options{})
	if err != nil || len(res.Data) == 0 {
		return nil, err
	}
	return res.Data[0].(*dal.Record), nil
}
Warning: Retrieving all the fields of a record, and of its linked records, is expensive for large data models such as carts and orders: they have many relations that Query loads, and the totals that it computes. Select only the fields that you need.

Retrieve Linked Records#

Fields can also be properties of the records linked to the data model. Use a dot between the name of the relation and the property: comments.body. To retrieve all the properties of a linked record, use * after the dot: products.*.

The names of the relations are:

  • For a relationship of the module, the name of its property: comments and tags for a post.
  • For a module link, the alias of the linked data model: products for a post, since a post is linked to many products, and sales_channels, user, and so on. The Module Links chapters explain how the alias is defined.

You can go through many levels:

src/queries/query.go
// PostsWithLinkedRecords retrieves posts with their comments (a relationship
// of the blog module) and their linked products and sales channels (module
// links).
func PostsWithLinkedRecords(ctx context.Context, c Resolver) ([]any, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity: "post",
		Fields: []string{
			"id",
			"title",
			"comments.id",
			"comments.body",
			"products.*",
			"products.variants.*",
		},
	}, joiner.Options{})
	if err != nil {
		return nil, err
	}
	return res.Data, nil
}

The query above retrieves the posts, their comments (a relationship of the Blog Module), their linked products (a module link to the Product Module), and the variants of the products. Query retrieves each module's data with the module's service, so it makes one request for each module, not one for each record.

A linked record that's a list, such as comments or products, is a list of records in the result. A linked record that isn't a list is a record, and the property is left out of the result when no record is linked.

The _link property of a link returns the link's rows, with their custom columns. Learn more in the Custom Columns chapter.


Apply Filters#

Pass the filters in the Filters field, as a *dal.Record whose keys are properties and whose values are the values to match:

src/queries/query.go
// PublishedPosts retrieves the published posts of an author.
func PublishedPosts(ctx context.Context, c Resolver, authorID string) ([]any, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity: "post",
		Fields: []string{"id", "title"},
		Filters: rec(
			"status", "published",
			"author_id", authorID,
		),
	}, joiner.Options{})
	if err != nil {
		return nil, err
	}
	return res.Data, nil
}

The query above retrieves the published posts of an author. All the filters of a record must match.

To match one of many values, pass a list:

src/queries/query.go
func filterIn() *dal.Record {
	return rec("id", []any{"post_123", "post_456"})
}

Advanced Query Filters#

A filter's value can also be a record with operators:

| Operator | Matches | | --- | --- | | $eq, $ne | Equal, or not equal, to a value. $ne with nil matches a property that isn't null. | | $in, $nin | A value that is in a list, or is not. | | $gt, $gte, $lt, $lte | Greater than, greater than or equal to, less than, or less than or equal to a value. | | $like, $ilike | A pattern, with % as a wildcard. $ilike ignores the case. | | $re | A regular expression. | | $exists | A property that's set (true) or is null (false). | | $contains, $contained, $overlap | For properties that hold lists or JSON: containing, contained in, or overlapping a value. | | $not | Negates an operator. |

The $and and $or keys combine filters, and $not negates one:

src/queries/query.go
func filterOperators() []*dal.Record {
	return []*dal.Record{
		// not equal
		rec("status", rec("$ne", "draft")),
		// not in a list of values
		rec("status", rec("$nin", []any{"draft", "archived"})),
		// a range
		rec("views", rec("$gte", float64(100), "$lt", float64(1000))),
		// like (case-insensitive: $ilike)
		rec("title", rec("$ilike", "%medusa%")),
		// is not null
		rec("published_at", rec("$ne", nil)),
		// or
		rec("$or", []any{
			rec("status", "published"),
			rec("views", rec("$gt", float64(1000))),
		}),
	}
}

Filter a Relation's Property

To filter by a property of a relationship, use its name as the key, with a record of filters as the value:

src/queries/query.go
// PostsByCommenter retrieves the posts that have a comment by Ada.
func PostsByCommenter(ctx context.Context, c Resolver) ([]any, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity:  "post",
		Fields:  []string{"id", "title", "comments.*"},
		Filters: rec("comments", rec("author_name", "Ada")),
	}, joiner.Options{})
	if err != nil {
		return nil, err
	}
	return res.Data, nil
}

The query retrieves the posts that have a comment by Ada.

Filter by Linked Data Model's Property#

You can filter by a property of a data model that's linked through a module link, in the same way:

src/queries/query.go
// PostsOfProduct retrieves the posts linked to the product with a handle.
func PostsOfProduct(ctx context.Context, c Resolver, handle string) ([]any, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity:  "post",
		Fields:  []string{"id", "title", "products.*"},
		Filters: rec("products", rec("handle", handle)),
	}, joiner.Options{})
	if err != nil {
		return nil, err
	}
	return res.Data, nil
}

Query applies the filter on the other module's data. When it can't turn the filter into a SQL condition of the module's own query, it retrieves the linked records, filters the results in memory, and applies the pagination afterwards, so the results and their count are correct.


Apply Pagination#

To paginate the records, pass a record in Pagination, with these keys:

| Key | Description | | --- | --- | | skip | The number of records to skip. | | take | The number of records to retrieve. | | order | The order of the records, as a record whose keys are properties and whose values are "ASC" or "DESC". |

When you set skip, the result has Metadata, with the count of all the records that match the filters, and the skip and take that you passed:

src/queries/query.go
// Page is one page of posts and the total number of posts.
type Page struct {
	Posts []any
	Count int
}

// PostsPage retrieves a page of posts, the newest first.
func PostsPage(ctx context.Context, c Resolver, offset, limit int) (*Page, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity: "post",
		Fields: []string{"id", "title"},
		Pagination: rec(
			"skip", float64(offset),
			"take", float64(limit),
			"order", rec("created_at", "DESC"),
		),
	}, joiner.Options{})
	if err != nil {
		return nil, err
	}

	// the metadata is set when skip is passed
	count, _ := res.Metadata.Get("count")
	return &Page{Posts: res.Data, Count: int(count.(float64))}, nil
}
Note: The numbers of the pagination are float64 or int values, and the count of the metadata is a float64.

Retrieve Only the Count#

To get only the number of records, paginate with a take of 0. Query returns no records, and the count of the metadata:

src/queries/query.go
// CountPosts returns the number of posts without retrieving them.
func CountPosts(ctx context.Context, c Resolver) (int, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity:     "post",
		Fields:     []string{"id"},
		Pagination: rec("skip", float64(0), "take", float64(0)),
	}, joiner.Options{})
	if err != nil {
		return 0, err
	}
	count, _ := res.Metadata.Get("count")
	return int(count.(float64)), nil
}

Sort Records#

The order key of the pagination sorts the records, as in the PostsPage example above, which sorts by created_at in descending order. Without an order, the records are sorted by their primary key, in ascending order.


Retrieve Deleted Records#

Query doesn't retrieve the soft-deleted records by default. Set WithDeleted to true to retrieve them as well. To retrieve only the deleted records, also filter by a deleted_at that isn't null:

src/queries/query.go
// DeletedPosts retrieves only the posts that were soft-deleted.
func DeletedPosts(ctx context.Context, c Resolver) ([]any, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity:      "post",
		Fields:      []string{"id", "title", "deleted_at"},
		Filters:     rec("deleted_at", rec("$ne", nil)),
		WithDeleted: true,
	}, joiner.Options{})
	if err != nil {
		return nil, err
	}
	return res.Data, nil
}

Retrieve Localized Data#

If the Translation Module is enabled, set the Locale option to retrieve the translatable properties in a locale:

src/queries/query.go
// LocalizedPosts retrieves the posts translated for a locale. It needs the
// Translation Module.
func LocalizedPosts(ctx context.Context, c Resolver) ([]any, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity: "post",
		Fields: []string{"id", "title"},
	}, joiner.Options{Locale: "fr-FR"})
	if err != nil {
		return nil, err
	}
	return res.Data, nil
}

Configure Query to Throw Error#

By default, Query returns an empty result when the records you filter by ID don't exist. Set the ThrowIfKeyNotFound option to make Query return a NOT_FOUND error that names the IDs that it couldn't find:

src/queries/query.go
// RetrievePost returns a NOT_FOUND error when no post has the ID, instead of
// an empty result.
func RetrievePost(ctx context.Context, c Resolver, id string) (*dal.Record, error) {
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity:  "post",
		Fields:  []string{"id", "title"},
		Filters: rec("id", id),
	}, joiner.Options{ThrowIfKeyNotFound: true})
	if err != nil {
		return nil, err
	}
	return res.Data[0].(*dal.Record), nil
}

The ThrowIfRelationNotFound option does the same for the linked records that Query retrieves by ID.


Cache Query Results#

If the caching module and the caching feature flag are enabled, you can cache the result of a query with the Cache option. The caching.CacheOptions value has these fields:

| Field | Description | | --- | --- | | Enable | Enables or disables the cache for this query. The cache is enabled when any other field is set. | | Key | The cache key. By default, MedusaGo computes it from the query. | | Tags | The tags of the cached entry, to invalidate it. | | ComputeAutomaticTags | Whether MedusaGo adds tags from the records that the query retrieves. | | TTL | The time to live of the entry, in seconds. | | AutoInvalidate | Whether the entry is invalidated automatically when its records change. | | Providers | The caching providers to use. |

src/queries/query.go
// CachedPosts caches the result of the query. It needs the caching module,
// and the caching feature flag.
func CachedPosts(ctx context.Context, c Resolver) ([]any, error) {
	ttl := 60
	res, err := queryOf(c).Graph(ctx, query.GraphInput{
		Entity: "post",
		Fields: []string{"id", "title"},
	}, joiner.Options{Cache: &caching.CacheOptions{
		Key:                  "published-posts",
		Tags:                 []string{"posts"},
		TTL:                  &ttl,
		ComputeAutomaticTags: true,
	}})
	if err != nil {
		return nil, err
	}
	return res.Data, nil
}

Without the module or the feature flag, the option has no effect, and Query runs the query.


Query for Your Modules#

Query builds the graph at startup from the DML data models of the configured modules and the links that your project defines. A module of your application is in the graph once it's registered in medusa-config.go, and its links are defined before the application loads. You can then retrieve its data models, such as post, with the same calls.

The data models of the graph come from DML definitions: Query can't retrieve data that a module doesn't define as a DML data model.

Found a problem with this page?Report Issue
Edited Nov 14·Edit this page