Introduction

Modern backend systems often need to expose flexible APIs while maintaining strong type safety and performance. GraphQL provides a powerful query language for clients, while SQLC generates type‑safe Go code directly from SQL queries. By combining these two tools, we can build APIs that are both expressive and reliable.

In this article, we’ll walk through a recent change to a Go project that integrates SQLC with GraphQL, focusing on a Product domain. We’ll see how filters, pagination, and integration tests come together to form a robust pipeline.

Github Go-GraphQL Project

The Change: Adding Product Filters and Pagination

We extended the GraphQL schema to support filtering and pagination:

graphql
scalar Time
scalar Int64
input ProductFilter {
  id: Int  name: String  description: String  minPrice: Int64  maxPrice: Int64  isActive: Boolean
}

input PaginationInput {
  limit: Int  offset: Int
}

type Product {
  id: Int!  name: String!  description: String!  price: Int64!  isActive: Boolean!
}

type ProductConnection {
  products: [Product!]!  total: Int!
}

type Query {
  product(id: Int!): Product  products(filter: ProductFilter, pagination: PaginationInput):
  ProductConnection!
}

This schema allows clients to query products with flexible filters (by ID, name, description, price range, and active status) and control pagination.

Mapping GraphQL Inputs to SQLC Parameters

To bridge GraphQL inputs with SQLC queries, we introduced utility functions that convert GraphQL pointers into sql.Null* types:

go
func ToNullInt64(val *int64) sql.NullInt64 {
    if val == nil {
        return sql.NullInt64{
            Valid: false
        }
    }
    return sql.NullInt64{
        Int64: *val, Valid: true
    }
}

These helpers ensure that optional GraphQL fields map cleanly into SQLC’s nullable parameters.

The resolver then translates GraphQL filters into SQLC query parameters:

go
func (s *Product) graphqlFilterToSQLCParams(
filter *model.ProductFilter,
pagination *model.PaginationInput,) sqlc.ListProductsWithFiltersParams {
    params := sqlc.ListProductsWithFiltersParams{
        Limit: sql.NullInt64{
            Int64: 10, Valid: true
        }, Offset: sql.NullInt64{
            Int64: 0, Valid: true
        },
    }
    if filter != nil {
        params.ID = utils.ToNullInt32(filter.ID) params.ProductName =
        utils.ToNullString(filter.Name) params.ProductDescription =
        utils.ToNullString(filter.Description) params.MinPrice = utils.ToNullInt64(filter.MinPrice)
        params.MaxPrice = utils.ToNullInt64(filter.MaxPrice) params.IsActive =
        utils.ToNullBool(filter.IsActive)
    }
    if pagination != nil {
        if pagination.Limit != nil {
            params.Limit = sql.NullInt64{
                Int64: int64(*pagination.Limit), Valid: true
            }
        }
        if pagination.Offset != nil {
            params.Offset = sql.NullInt64{
                Int64: int64(*pagination.Offset), Valid: true
            }
        }
    }
    return params
}

SQLC Queries

The SQLC queries define the filtering logic directly in SQL:

sql
-- name: ListProductsWithFilters :many
SELECT
    id,
    product_name,
    product_description,
    price,
    is_active,
    created_at
FROM products
WHERE (sqlc.narg('id')::int IS NULL OR id = sqlc.narg('id'))
  AND (
      sqlc.narg('product_name')::text IS NULL
      OR product_name ILIKE '%' || sqlc.narg('product_name') || '%'
  )
  AND (
      sqlc.narg('min_price')::bigint IS NULL
      OR price >= sqlc.narg('min_price')
  )
  AND (
      sqlc.narg('max_price')::bigint IS NULL
      OR price <= sqlc.narg('max_price')
  )
  AND (
      sqlc.narg('is_active')::bool IS NULL
      OR is_active = sqlc.narg('is_active')
  )
  AND (
      sqlc.narg('product_description')::text IS NULL
      OR product_description ILIKE '%' || sqlc.narg('product_description') || '%'
  )
ORDER BY id
LIMIT sqlc.narg('limit')
OFFSET sqlc.narg('offset');

-- name: CountProductsWithFilters :one
SELECT COUNT(*) AS count
FROM products
WHERE (sqlc.narg('id')::int IS NULL OR id = sqlc.narg('id'))
  AND (
      sqlc.narg('product_name')::text IS NULL
      OR product_name ILIKE '%' || sqlc.narg('product_name') || '%'
  )
  AND (
      sqlc.narg('min_price')::bigint IS NULL
      OR price >= sqlc.narg('min_price')
  )
  AND (
      sqlc.narg('max_price')::bigint IS NULL
      OR price <= sqlc.narg('max_price')
  )
  AND (
      sqlc.narg('is_active')::bool IS NULL
      OR is_active = sqlc.narg('is_active')
  )
  AND (
      sqlc.narg('product_description')::text IS NULL
      OR product_description ILIKE '%' || sqlc.narg('product_description') || '%'
  );

Integration Tests

To validate the integration, we wrote end‑to‑end tests that:

  1. Create a product via the admin API.
  2. Query the product by ID using GraphQL.
  3. Query the product list with filters and pagination.
  4. Clean up by deleting the product.

Example test for filters:

go
func queryProductsByFilter(t *testing.T, addr, filter string, validate func(map[string]interface{
}) bool) {
    query :=
    fmt.Sprintf(`{"query":"query { products(filter: { %s }) { products { id name description price isActive } total } }"}`, filter)
    resp, err := http.Post(addr+"/query", "application/json", bytes.NewBufferString(query))
    //...decode response and validate results...
}

This ensures that each filter (id, name, description, minPrice, maxPrice, isActive) behaves correctly against the database.

Benefits of SQLC + GraphQL Integration

  • Type Safety: SQLC generates Go structs and query methods directly from SQL, reducing runtime errors.
  • Flexibility: GraphQL allows clients to specify exactly what they need, with filters and pagination.
  • Maintainability: Utility functions centralize nullability handling, making code cleaner.
  • Testability: Integration tests validate the full pipeline, from GraphQL input to SQL execution.

Git commit

Conclusion

By integrating SQLC with GraphQL, we’ve built a backend that combines the strengths of both worlds: SQLC’s type‑safe database access and GraphQL’s flexible query language. This change makes the API more powerful, maintainable, and reliable.

It’s a strong foundation for scaling product queries, and the same pattern can be extended to other domains like orders, users, or analytics.