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:
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:
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:
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:
-- 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:
- Create a product via the admin API.
- Query the product by ID using GraphQL.
- Query the product list with filters and pagination.
- Clean up by deleting the product.
Example test for filters:
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.
