Skip to content

The typed query surface

atlantis generates a typed gRPC service per entity, exposing Get, BatchGet, Create, Update, Delete, and a predicate-based Query method.

For each entity, atlantis generates a typed gRPC service. The service exposes primary-key Get, BatchGet, Create, Update, and Delete methods, plus a Query method that takes a predicate tree and compiles it to parameterised SQL on the server.

The request and response messages come from the entity’s .atl declaration, so a field rename or type change becomes a compile error in the caller.

How Query works

Query filters rows of a single entity; it does not aggregate or join. The request takes a structured filter expression built from per-entity predicate messages generated from the schema:

&pb.QueryNoteRequest{
    Filter: &pb.NoteFilter{
        Title: &commonpb.StringPredicate{
            Op: &commonpb.StringPredicate_Contains{Contains: "draft"},
        },
        CreatedAt: &commonpb.TimestampPredicate{
            Op: &commonpb.TimestampPredicate_Gte{Gte: timestamppb.New(after)},
        },
    },
    Order: []*pb.NoteOrderBy{
        {Field: pb.NoteOrderField_NOTE_ORDER_FIELD_CREATED_AT, Desc: true},
    },
    Limit: 100,
}

Each scalar field type has a corresponding predicate. A predicate holds one operator, so Op takes a single wrapper value; combine operators with the filter’s And, Or and Not arms.

Operators

Predicate .atl types Operators
StringPredicate text, varchar, citext, uuid, char, and every type the types table maps to proto string eq, neq, in, not_in, prefix, suffix, contains, ilike, is_null, is_not_null
Int32Predicate smallint, int eq, neq, lt, lte, gt, gte, in, not_in, between, is_null, is_not_null
Int64Predicate bigint same as Int32Predicate
FloatPredicate, DoublePredicate real; double eq, neq, lt, lte, gt, gte, in, is_null, is_not_null
NumericPredicate numeric eq, neq, lt, lte, gt, gte, in, is_null, is_not_null
BoolPredicate boolean eq, is_null, is_not_null
TimestampPredicate timestamptz, timestamp, date eq, neq, lt, lte, gt, gte, between, is_null, is_not_null
BytesPredicate bytea, jsonb eq, neq, in, is_null, is_not_null

Array and vector columns have no predicate and cannot be filtered on; vector columns are searched through the Search method of an HNSW index.

  • prefix, suffix, contains and ilike take a literal, not a LIKE pattern. %, _ and \ in the value are escaped before the comparison, so contains: "50%" matches rows whose column holds 50%.
  • ilike is contains compared without case. It does not accept a pattern: ilike: "go %" matches only a column that contains the three characters go %.
  • between is inclusive at both ends; a range whose lo is above hi is rejected with InvalidArgument.
  • in and not_in take at most 1000 values; an empty list adds no condition.
  • is_null: true adds IS NULL; is_null: false adds nothing, and so does is_not_null: false.
  • NumericPredicate carries decimal strings, compared as Postgres numeric.
  • BytesPredicate on a jsonb column compares the bytes against Postgres’s normalised rendering; there is no value-level JSON predicate.

Update

Update writes the fields the request sets and keeps the rest. Whether a field counts as set depends on its shape on the wire:

Field Set when
Nullable or defaulted column (optional scalar) The caller assigned it, including to its zero value
Timestamp, date, interval The message is non-nil
Array or vector (repeated) The list has at least one element
NOT NULL column without a default (plain scalar) Always; an empty string is written as an empty string

An Update that carries only title does not touch created_at, body or tags.

update_mask overrides the presence rule. A non-empty mask names the fields to write and nothing else: a named field is written from its wire value, NULL when unset, which is the one way to clear a nullable column or an array through Update; a field left out of the mask is kept even when set, a plain scalar included. A path that names no updatable column, the primary key among them, is refused with InvalidArgument.

_, err := notes.UpdateNote(ctx, &pb.UpdateNoteRequest{
    Entity:     &pb.Note{Id: 7, Title: "Draft 2"},
    UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title", "body"}},
})
// title is written; body, unset, becomes NULL; every other column is kept.

Ordering and pages

Order is a list, applied left to right. atlantis appends the entity’s primary key to whatever you name, so the ordering is always total — two rows holding the same created_at still have a defined position, and a page boundary that falls between them advances.

Pages are keyset-based. A response that has more rows returns a NextPageToken; echo it in the next request to continue. The token records the last row’s position in the columns you ordered by, so send the same Order with the token. Changing the ordering part-way through invalidates the token: the server rejects one that names a different number of columns, and one that names the same number of different columns resumes from a position in the wrong columns.

Stop when NextPageToken is empty. Do not stop on a short page: Limit is clamped to 1000, so a request for more returns fewer rows than it asked for while more remain.

Fields and Includes are declared on the request and not yet served — the server answers Unimplemented rather than returning a row set that quietly ignores them. Read the columns you need from the full row, and fetch a related entity with its own Query.

Properties of a predicate tree

Typed predicates give three guarantees:

  • Injection safety. The server binds parameters from the predicate; there is no string to escape.
  • Cache-key stability. The query-result cache canonicalises equivalent expressions to one key.
  • Rename safety. A renamed field becomes a compile error in the caller’s generated code.

When Query isn’t enough

Query is a typed predicate over one entity. Reads that need GROUP BY, DISTINCT ON, multi-entity joins, sampling, or window functions do not fit; declare a query or procedure block instead. The server validates the SQL when you run tide apply, and the codegen emits a typed RPC.

Preview the custom SQL in the sandbox before applying.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close