Querying
Querying
- Querying
Overview
ic-dbms provides a powerful query API for retrieving data from your tables. Queries are built using the QueryBuilder
and can include:
- Filters - Narrow down which records to return
- Ordering - Sort results by one or more columns
- Pagination - Limit results and implement pagination
- Field Selection - Choose which columns to return
- Eager Loading - Load related records in a single query
- Joins - Combine rows from multiple tables
Query Builder
Basic Queries
Use Query::builder() to construct queries:
use ic_dbms_api::prelude::*;
// Select all records
let query = Query::builder().all().build();
// Select with filter
let query = Query::builder()
.filter(Filter::eq("status", Value::Text("active".into())))
.build();
// Complex query with multiple options
let query = Query::builder()
.filter(Filter::gt("age", Value::Int32(18.into())))
.order_by("created_at", OrderDirection::Descending)
.limit(10)
.offset(20)
.build();
Query Structure
A query consists of these optional components:
| Component | Method | Description |
|---|---|---|
| Filter | .filter() |
Which records to return |
| Select | .all() or .columns() |
Which columns to return |
| Order | .order_by() |
Sort order |
| Limit | .limit() |
Maximum records to return |
| Offset | .offset() |
Records to skip |
| Eager Loading | .with() |
Related tables to load |
| Join | .inner_join(), etc. |
Cross-table join |
Filters
Filters determine which records match your query. All filters are created using the Filter struct.
Comparison Filters
| Filter | Description | Example |
|---|---|---|
Filter::eq() |
Equal to | Filter::eq("status", Value::Text("active".into())) |
Filter::ne() |
Not equal to | Filter::ne("status", Value::Text("deleted".into())) |
Filter::gt() |
Greater than | Filter::gt("age", Value::Int32(18.into())) |
Filter::ge() |
Greater than or equal | Filter::ge("score", Value::Decimal(90.0.into())) |
Filter::lt() |
Less than | Filter::lt("price", Value::Decimal(100.0.into())) |
Filter::le() |
Less than or equal | Filter::le("quantity", Value::Int32(10.into())) |
Examples:
// Find users older than 21
let filter = Filter::gt("age", Value::Int32(21.into()));
// Find products under $50
let filter = Filter::lt("price", Value::Decimal(50.0.into()));
// Find orders from a specific date
let filter = Filter::ge("created_at", Value::DateTime(some_datetime));
List Membership
Check if a value is in a list of values:
// Find users with specific roles
let filter = Filter::in_list("role", vec![
Value::Text("admin".into()),
Value::Text("moderator".into()),
Value::Text("editor".into()),
]);
// Find products in certain categories
let filter = Filter::in_list("category_id", vec![
Value::Uint32(1.into()),
Value::Uint32(2.into()),
Value::Uint32(5.into()),
]);
Pattern Matching
Use like for pattern matching with wildcards:
| Pattern | Matches |
|---|---|
% |
Any sequence of characters |
_ |
Any single character |
%% |
Literal % character |
// Find users whose email ends with @company.com
let filter = Filter::like("email", "%@company.com");
// Find products starting with "Pro"
let filter = Filter::like("name", "Pro%");
// Find codes with pattern XX-###
let filter = Filter::like("code", "__-___");
// Find text containing literal %
let filter = Filter::like("description", "%%25%% off");
Null Checks
Check for null or non-null values:
// Find users without a phone number
let filter = Filter::is_null("phone");
// Find users with a profile picture
let filter = Filter::not_null("avatar_url");
Combining Filters
Filters can be combined using logical operators:
AND - Both conditions must match:
// Active users over 18
let filter = Filter::eq("status", Value::Text("active".into()))
.and(Filter::gt("age", Value::Int32(18.into())));
OR - Either condition matches:
// Admins or moderators
let filter = Filter::eq("role", Value::Text("admin".into()))
.or(Filter::eq("role", Value::Text("moderator".into())));
NOT - Negate a condition:
// Users who are not banned
let filter = Filter::eq("status", Value::Text("banned".into())).not();
Complex combinations:
// (active AND age > 18) OR role = "admin"
let filter = Filter::eq("status", Value::Text("active".into()))
.and(Filter::gt("age", Value::Int32(18.into())))
.or(Filter::eq("role", Value::Text("admin".into())));
// NOT (deleted OR archived)
let filter = Filter::eq("status", Value::Text("deleted".into()))
.or(Filter::eq("status", Value::Text("archived".into())))
.not();
JSON Filters
For columns with Json type, use specialized JSON filters. See the JSON Reference for
comprehensive documentation.
Quick examples:
// Check if JSON contains a pattern
let pattern = Json::from_str(r#"{"active": true}"#).unwrap();
let filter = Filter::json("metadata", JsonFilter::contains(pattern));
// Extract and compare a value
let filter = Filter::json(
"settings",
JsonFilter::extract_eq("theme", Value::Text("dark".into()))
);
// Check if a path exists
let filter = Filter::json("data", JsonFilter::has_key("user.email"));
Ordering
Single Column Ordering
Sort results by a single column:
// Sort by name ascending (A-Z)
let query = Query::builder()
.all()
.order_by("name", OrderDirection::Ascending)
.build();
// Sort by created_at descending (newest first)
let query = Query::builder()
.all()
.order_by("created_at", OrderDirection::Descending)
.build();
Multiple Column Ordering
Chain multiple order_by calls for secondary sorting:
// Sort by category, then by price within each category
let query = Query::builder()
.all()
.order_by("category", OrderDirection::Ascending)
.order_by("price", OrderDirection::Descending)
.build();
// Sort by status, then by priority, then by created_at
let query = Query::builder()
.all()
.order_by("status", OrderDirection::Ascending)
.order_by("priority", OrderDirection::Descending)
.order_by("created_at", OrderDirection::Ascending)
.build();
Pagination
Limit
Restrict the number of records returned:
// Get only the first 10 records
let query = Query::builder()
.all()
.limit(10)
.build();
Offset
Skip a number of records before returning results:
// Skip the first 20 records
let query = Query::builder()
.all()
.offset(20)
.build();
Pagination Pattern
Combine limit and offset for pagination:
const PAGE_SIZE: u64 = 20;
fn get_page_query(page: u64) -> Query<User> {
Query::builder()
.all()
.order_by("id", OrderDirection::Ascending) // Consistent ordering is important
.limit(PAGE_SIZE)
.offset(page * PAGE_SIZE)
.build()
}
// Page 0: records 0-19
let page_0 = get_page_query(0);
// Page 1: records 20-39
let page_1 = get_page_query(1);
// Page 2: records 40-59
let page_2 = get_page_query(2);
Tip: Always use
order_bywith pagination to ensure consistent ordering across pages.
Field Selection
Select All Fields
Use .all() to select all columns:
let query = Query::builder()
.all()
.build();
let users = client.select::<User>(User::table_name(), query, None).await? ?;
// All fields are populated
Select Specific Fields
Use .columns() to select only specific columns:
let query = Query::builder()
.columns(vec!["id".to_string(), "name".to_string(), "email".to_string()])
.build();
let users = client.select::<User>(User::table_name(), query, None).await? ?;
// Only id, name, and email are populated
// Other fields will have default values
Note: The primary key is always included, even if not specified.
Eager Loading
Load related records in a single query using .with():
// Define tables with foreign key
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[table = "posts"]
pub struct Post {
#[primary_key]
pub id: Uint32,
pub title: Text,
#[foreign_key(entity = "User", table = "users", column = "id")]
pub author_id: Uint32,
}
// Query posts with authors eagerly loaded
let query = Query::builder()
.all()
.with("users")
.build();
let posts = client.select::<Post>(Post::table_name(), query, None).await? ?;
See the Relationships Guide for more on eager loading.
Joins
Joins combine rows from two or more tables based on a related column, producing a single result set with columns from all joined tables. Use joins when you need to correlate data across tables in a single flat result — for example, listing posts alongside their author names.
Note: Joins only work through the untyped
select_rawpath (or theselectcanister endpoint). Typedselect::<T>rejects queries that contain joins with aJoinInsideTypedSelecterror.
Join Types
| Type | Builder Method | Description |
|---|---|---|
| INNER | .inner_join() |
Returns only rows where both sides match |
| LEFT | .left_join() |
Returns all left rows; unmatched right columns are NULL |
| RIGHT | .right_join() |
Returns all right rows; unmatched left columns are NULL |
| FULL | .full_join() |
Returns all rows from both sides; unmatched columns are NULL |
Basic Join
Use .inner_join(table, left_column, right_column) to join two tables:
use ic_dbms_api::prelude::*;
// Join users with their posts (INNER JOIN)
let query = Query::builder()
.all()
.inner_join("posts", "id", "user_id")
.build();
// Use select_raw since joins return untyped rows
let rows = client.select_raw("users", query, None).await??;
// Each row contains columns from both "users" and "posts"
for row in &rows {
for (col_def, value) in row {
// col_def.table tells you which table the column came from
println!("{}.{} = {:?}", col_def.table.as_deref().unwrap_or("?"), col_def.name, value);
}
}
Left, Right, and Full Joins
// LEFT JOIN: all users, even those without posts
let query = Query::builder()
.all()
.left_join("posts", "id", "user_id")
.build();
// RIGHT JOIN: all posts, even those with missing/deleted authors
let query = Query::builder()
.all()
.right_join("posts", "id", "user_id")
.build();
// FULL JOIN: all users and all posts, matched where possible
let query = Query::builder()
.all()
.full_join("posts", "id", "user_id")
.build();
For LEFT, RIGHT, and FULL joins, columns from the unmatched side are filled with Value::Null.
Chaining Multiple Joins
Chain multiple joins to combine more than two tables:
// Users → Posts → Comments
let query = Query::builder()
.all()
.inner_join("posts", "id", "user_id")
.left_join("comments", "posts.id", "post_id")
.build();
let rows = client.select_raw("users", query, None).await??;
Joins are processed left-to-right. The second join operates on the result of the first.
Qualified Column Names
When joining tables that share column names, use table.column syntax to disambiguate:
// Both "users" and "posts" have an "id" column
let query = Query::builder()
.field("users.id")
.field("users.name")
.field("posts.title")
.inner_join("posts", "users.id", "user_id")
.and_where(Filter::eq("users.name", Value::Text("Alice".into())))
.order_by_asc("posts.title")
.build();
Qualified names (table.column) work in:
- Field selection (
.field()) - Filters (
.and_where(),.or_where()) - Ordering (
.order_by_asc(),.order_by_desc()) - Join ON conditions
Unqualified names default to the FROM table (the table passed to select_raw).
Joins vs Eager Loading
| Eager Loading | Joins | |
|---|---|---|
| Result type | Typed (Vec<T>) |
Untyped (Vec<Vec<(CandidColumnDef, Value)>>) |
| Result format | Separate related records | Flat combined rows |
| API method | select::<T> |
select_raw / select endpoint |
| Column disambiguation | Not needed | Use table.column syntax |
| Use case | Load parent with children | Correlate columns across tables |
Use eager loading when you want typed results with related records attached. Use joins when you need a flat, cross-table result set — for example, for reporting, search, or when you need columns from multiple tables in a single row.