Skip to content

Custom Queries — Filtering, Search, and Pagination

This guide picks up where Adding Mutations left off. You have a working blog API with queries and mutations. Now you make the posts query useful in production: filtering by author or published state, full-text search, cursor-based pagination, and sorting.

FraiseQL uses automatic-where: any argument declared on a query that matches a column name exposed by the backing view is automatically appended as a WHERE clause. No resolver code required.

query {
posts(is_published: true, author_id: "usr_01HZ3K") { ... }
}

FraiseQL sees is_published and author_id, finds matching columns in v_post, and issues:

SELECT data FROM v_post
WHERE is_published = true
AND author_id = 'usr_01HZ3K'

For arguments that do not map 1:1 to a column — like search — you embed the logic in the view itself (a generated column or a virtual table) and expose a column that FraiseQL can filter on.


The v_post view from Your First API exposes id and data. To support automatic-where on is_published and author_id, those values must be top-level columns in the view. For full-text search, you add a search_vector column.

Replace db/schema/02_read/v_post.sql:

db/schema/02_read/v_post.sql
CREATE VIEW v_post AS
SELECT
p.id,
/*filtering columns — matched by automatic-where*/
p.is_published,
u.id AS author_id,
p.created_at,
/*full-text search vector*/
to_tsvector('english',
coalesce(p.title, '') || ' ' || coalesce(p.content, '')
) AS search_vector,
jsonb_build_object(
'id', p.id::text,
'title', p.title,
'slug', p.slug,
'content', p.content,
'is_published', p.is_published,
'created_at', p.created_at,
'updated_at', p.updated_at,
'author', vu.data,
'comments', COALESCE(
jsonb_agg(vc.data) FILTER (WHERE vc.id IS NOT NULL),
'[]'::jsonb
)
) AS data
FROM tb_post p
JOIN tb_user u ON u.pk_user = p.fk_user
JOIN v_user vu ON vu.id = u.id
LEFT JOIN v_comment vc ON vc.fk_post = p.pk_post
GROUP BY p.pk_post, u.id, vu.data;

The search_vector column is a tsvector. FraiseQL passes a search argument as a tsquery match against this column — see Step 2 for the schema declaration.

Add an index so searches stay fast:

db/schema/01_write/ix_post_search.sql
CREATE INDEX ix_tb_post_search
ON tb_post
USING GIN (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,'')));

Rebuild the database after changing views:

Terminal window
confiture build --env local

Step 2: Declare Filtering Arguments in the Schema

Section titled “Step 2: Declare Filtering Arguments in the Schema”

Add is_published, author_id, and search to the posts query. FraiseQL matches these to the view columns added in Step 1.

schema.py
@fraiseql.type
class Post:
id: ID
title: str
slug: Slug
content: str
is_published: bool
created_at: DateTime
updated_at: DateTime
author: User
comments: list[Comment]
@fraiseql.query(sql_source="v_post")
def posts(
is_published: bool | None = None,
author_id: ID | None = None,
search: str | None = None,
) -> list[Post]:
"""List posts. All arguments are optional and combinable."""
pass

is_published and author_id are matched verbatim to view columns — automatic-where handles them. search is declared as a full-text argument:

schema.py
@fraiseql.query(sql_source="v_post")
def posts(
is_published: bool | None = None,
author_id: ID | None = None,
search: Annotated[str | None, fraiseql.FullText("search_vector")] = None,
) -> list[Post]:
pass

The fraiseql.FullText("search_vector") annotation tells FraiseQL to rewrite the WHERE clause as search_vector @@ plainto_tsquery('english', $1) on PostgreSQL instead of a plain equality check.

Test the filters in the playground:

# Published posts only
query {
posts(isPublished: true) {
title
author { username }
}
}
# Posts by a specific author
query {
posts(authorId: "usr_01HZ3K") {
title
isPublished
}
}
# Full-text search
query {
posts(search: "database performance") {
title
slug
}
}
# Combined
query {
posts(isPublished: true, search: "GraphQL") {
title
author { username }
}
}

FraiseQL uses cursor-based pagination: clients pass first (page size) and after (an opaque cursor) and receive a Relay-style connection type with edges, node, and pageInfo.

The cursor encodes the pk_post value as a base64 string. The view must expose pk_post as a filterable column so FraiseQL can apply WHERE pk_post > :after_cursor ORDER BY pk_post LIMIT :first.

Update the view to expose the cursor column

Section titled “Update the view to expose the cursor column”
db/schema/02_read/v_post.sql
CREATE VIEW v_post AS
SELECT
p.id,
p.pk_post, /*pagination cursor*/
p.is_published,
u.id AS author_id,
p.created_at,
to_tsvector('english',
coalesce(p.title, '') || ' ' || coalesce(p.content, '')
) AS search_vector,
jsonb_build_object(
'id', p.id::text,
'title', p.title,
'slug', p.slug,
'content', p.content,
'is_published', p.is_published,
'created_at', p.created_at,
'updated_at', p.updated_at,
'author', vu.data,
'comments', COALESCE(
jsonb_agg(vc.data) FILTER (WHERE vc.id IS NOT NULL),
'[]'::jsonb
)
) AS data
FROM tb_post p
JOIN tb_user u ON u.pk_user = p.fk_user
JOIN v_user vu ON vu.id = u.id
LEFT JOIN v_comment vc ON vc.fk_post = p.pk_post
GROUP BY p.pk_post, u.id, vu.data;

Declare pagination arguments in the schema

Section titled “Declare pagination arguments in the schema”
schema.py
from fraiseql import connection
@fraiseql.type
class PageInfo:
has_next_page: bool
end_cursor: str | None
@fraiseql.type
class PostEdge:
cursor: str
node: Post
@fraiseql.type
class PostConnection:
edges: list[PostEdge]
page_info: PageInfo
@fraiseql.query(sql_source="v_post", cursor_column="pk_post")
def posts(
first: int = 20,
after: str | None = None,
is_published: bool | None = None,
author_id: ID | None = None,
search: Annotated[str | None, fraiseql.FullText("search_vector")] = None,
) -> PostConnection:
pass

cursor_column="pk_post" tells FraiseQL which column to use for the WHERE pk_post > :cursor clause. The cursor value is base64-encoded by FraiseQL before being returned to the client and decoded on the way in.

query {
posts(first: 2, isPublished: true) {
edges {
cursor
node {
title
author { username }
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
{
"data": {
"posts": {
"edges": [
{
"cursor": "cGtfcG9zdDox",
"node": {
"title": "Hello World",
"author": { "username": "alice" }
}
},
{
"cursor": "cGtfcG9zdDoy",
"node": {
"title": "Getting Started with FraiseQL",
"author": { "username": "bob" }
}
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "cGtfcG9zdDoy"
}
}
}
}

Fetch the next page by passing endCursor as after:

query {
posts(first: 2, after: "cGtfcG9zdDoy", isPublished: true) {
edges {
cursor
node { title }
}
pageInfo {
hasNextPage
endCursor
}
}
}

Declare an order_by enum argument to control sort order. FraiseQL maps enum values to ORDER BY expressions you define in the schema configuration.

schema.py
import enum
@fraiseql.enum
class PostOrderBy(enum.Enum):
CREATED_AT_ASC = "created_at ASC"
CREATED_AT_DESC = "created_at DESC"
TITLE_ASC = "title ASC"
@fraiseql.query(sql_source="v_post", cursor_column="pk_post")
def posts(
first: int = 20,
after: str | None = None,
is_published: bool | None = None,
author_id: ID | None = None,
search: Annotated[str | None, fraiseql.FullText("search_vector")] = None,
order_by: PostOrderBy = PostOrderBy.CREATED_AT_DESC,
) -> PostConnection:
pass

The enum value string is used verbatim in the ORDER BY clause. FraiseQL validates that the value matches a declared enum member — arbitrary strings cannot be injected.

When using cursor pagination together with sorting, the order_by argument must be stable between pages. FraiseQL encodes the active sort order into the cursor so that after always refers to the correct position regardless of the order_by value — but changing order_by between pages invalidates existing cursors and restarts from the beginning.


All features combined. Run these in the playground at http://localhost:8080/graphql.

query {
posts(first: 10, isPublished: true, orderBy: CREATED_AT_DESC) {
edges {
node {
title
slug
createdAt
author { username }
}
}
pageInfo { hasNextPage endCursor }
}
}
query {
posts(first: 5, search: "GraphQL tutorial", orderBy: CREATED_AT_DESC) {
edges {
cursor
node {
title
isPublished
author { username }
}
}
pageInfo { hasNextPage endCursor }
}
}
query {
posts(
authorId: "usr_01HZ3K"
isPublished: true
orderBy: TITLE_ASC
first: 20
) {
edges {
node {
title
slug
comments { content }
}
}
pageInfo { hasNextPage endCursor }
}
}
query {
posts(
first: 5
after: "cGtfcG9zdDo1"
isPublished: true
orderBy: CREATED_AT_DESC
) {
edges {
node { title }
}
pageInfo { hasNextPage endCursor }
}
}