Filtering, sorting, and pagination with the fluent query builder

posted Originally published at dev.to 3 min read

title: 'Filtering, sorting, and pagination with the fluent query builder'
published: true
tags: 'delphi, orm, database, opensource'
series: 'Trysil: a Delphi ORM deep dive'

description: 'How to use TTFilterBuilder to build type-safe queries with WHERE clauses, sorting, and pagination — no raw SQL needed.'

So far in this series we have mapped entities, validated them, and performed basic CRUD. But real applications rarely load all records from a table. You need to search, filter, sort, and paginate.

Trysil provides a fluent API for this: TTFilterBuilder<T>. You chain method calls to build a filter, then pass it to Select<T>. The builder generates parameterized SQL behind the scenes — no string concatenation, no injection risk.

Basic filtering

uses
  Trysil.Filter;

var
  LBuilder: TTFilterBuilder<TTContact>;
  LFilter: TTFilter;
  LContacts: TTList<TTContact>;
begin
  LContacts := TTList<TTContact>.Create;
  try
    LBuilder := LContext.CreateFilterBuilder<TTContact>();
    try
      LFilter := LBuilder
        .Where('Lastname').Equal('Smith')
        .Build;

      LContext.Select<TTContact>(LContacts, LFilter);
    finally
      LBuilder.Free;
    end;

    for LContact in LContacts do
      WriteLn(Format('%s %s', [LContact.Firstname, LContact.Lastname]));
  finally
    LContacts.Free;
  end;
end;

The flow is always the same:

  1. Create a builder with LContext.CreateFilterBuilder<T>
  2. Chain conditions with .Where, .AndWhere, .OrWhere
  3. Apply an operator (.Equal, .Like, .Greater, etc.)
  4. Call .Build to get a TTFilter
  5. Pass the filter to LContext.Select<T>

Available operators

Method SQL
.Equal(value) = :param
.NotEqual(value) <> :param
.Greater(value) > :param
.GreaterOrEqual(value) >= :param
.Less(value) < :param
.LessOrEqual(value) <= :param
.Like(pattern) LIKE :param
.NotLike(pattern) NOT LIKE :param
.IsNull IS NULL
.IsNotNull IS NOT NULL

Combining conditions

Use .AndWhere and .OrWhere to combine multiple conditions:

LFilter := LBuilder
  .Where('Lastname').Equal('Smith')
  .AndWhere('Email').IsNotNull
  .Build;
LFilter := LBuilder
  .Where('City').Equal('Rome')
  .OrWhere('City').Equal('Milan')
  .Build;

Pattern matching with LIKE

LFilter := LBuilder
  .Where('Lastname').Like('Sm%')
  .Build;

Standard SQL wildcards apply: % matches any sequence of characters, _ matches a single character.

Sorting

LFilter := LBuilder
  .Where('Country').Equal('Italy')
  .OrderByAsc('Lastname')
  .Build;

You can chain multiple sort clauses:

LFilter := LBuilder
  .Where('Country').Equal('Italy')
  .OrderByAsc('Lastname')
  .OrderByAsc('Firstname')
  .Build;

For descending order, use .OrderByDesc:

LFilter := LBuilder
  .OrderByDesc('Price')
  .Build;

Note: you can sort without filtering — just skip the .Where call.

Pagination

For large datasets, load data in pages:

const
  PageSize = 20;
var
  LPage: Integer;
begin
  LPage := 3; // zero-based page index

  LFilter := LBuilder
    .OrderByAsc('Lastname')
    .Limit(PageSize)
    .Offset(LPage * PageSize)
    .Build;

  LContext.Select<TTContact>(LContacts, LFilter);
end;

.Limit(n) sets the maximum number of rows to return. .Offset(n) skips the first n rows. Combined with sorting, this gives you clean, predictable pagination.

Counting records

Sometimes you need the total count (for example, to display "Page 3 of 12"):

var
  LCount: Integer;
begin
  LFilter := LBuilder
    .Where('Country').Equal('Italy')
    .Build;

  LCount := LContext.SelectCount<TTContact>(LFilter);
end;

SelectCount<T> returns the number of matching rows without loading the entities into memory.

A complete example

Here is a realistic search function that combines filtering, sorting, pagination, and counting:

procedure TContactService.Search(
  const ASearchText: String;
  const APage: Integer;
  const APageSize: Integer;
  const AContacts: TTList<TTContact>;
  out ATotalCount: Integer);
var
  LBuilder: TTFilterBuilder<TTContact>;
  LFilter: TTFilter;
begin
  LBuilder := FContext.CreateFilterBuilder<TTContact>();
  try
    if not ASearchText.IsEmpty then
      LBuilder
        .Where('Lastname').Like(Format('%s%%', [ASearchText]))
        .OrWhere('Firstname').Like(Format('%s%%', [ASearchText]));

    LBuilder
      .OrderByAsc('Lastname')
      .OrderByAsc('Firstname');

    LFilter := LBuilder.Build;
    ATotalCount := FContext.SelectCount<TTContact>(LFilter);

    LBuilder
      .Limit(APageSize)
      .Offset(APage * APageSize);

    LFilter := LBuilder.Build;
    FContext.Select<TTContact>(AContacts, LFilter);
  finally
    LBuilder.Free;
  end;
end;

What is next

We can now search, sort, and paginate our data with a clean fluent API. In the next article we will tackle relations and lazy loading — how to model parent-child relationships and load related entities on demand.


Trysil is open-source and available on GitHub. Stars and feedback are always appreciated!

More Posts

Meet Trysil: a lightweight ORM for Delphi

davidlastrucci - Apr 20

The Hidden Program Behind Every SQL Statement

lovestacoverified - Apr 11

Just completed another large-scale WordPress migration — and the client left this

saqib_devmorph - Apr 7

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

I added callbacks, i18n, sorting & pagination to my React accounting component — here's what changed

Balock Ruthel - Apr 9
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!