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,containsandiliketake a literal, not aLIKEpattern.%,_and\in the value are escaped before the comparison, socontains: "50%"matches rows whose column holds50%.ilikeiscontainscompared without case. It does not accept a pattern:ilike: "go %"matches only a column that contains the three charactersgo %.betweenis inclusive at both ends; a range whoselois abovehiis rejected withInvalidArgument.inandnot_intake at most 1000 values; an empty list adds no condition.is_null: trueaddsIS NULL;is_null: falseadds nothing, and so doesis_not_null: false.NumericPredicatecarries decimal strings, compared as Postgresnumeric.BytesPredicateon ajsonbcolumn 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.
Related
- Custom queries and procedures
- Caching and invalidation — how
Queryresults are cached. - The DSL types — which proto type, and so which predicate, each column gets.
- The sandbox — disposable copies of the schema for testing predicates and custom SQL.