inquiry is a small Rust crate for people who want simple SQLx-backed query
builders without writing the same CRUD glue for every struct.
You define a model, derive Queryable, and inquiry gives you a typed query
builder for that model. It can create the table, insert rows, upsert by primary
key, update by primary key, fetch rows, and delete rows with field-specific
filters.
The crate currently targets SQLx and PostgreSQL.
For a model like Player, inquiry generates:
Player::query(pool)- a query builder named
PlayerQuery<T> PlayerQueryError- table creation helpers
- insert, upsert, update, fetch, count, exists, and delete methods
by_<field>equality filterswhere_<field>operator filters
The operator types are shared by the library:
QueryOperatorfor text fieldsQueryOrderingOperatorfor numeric fieldsQueryEqualityOperatorfor fields that only support equality checks
That keeps generated models usable across normal Rust modules without creating duplicate operator enums for each model.
use inquiry::{Queryable, QueryOperator, QueryOrderingOperator};
use sqlx::PgPool;
#[derive(sqlx::FromRow, Queryable, Debug)]
#[query(table = "players")]
struct Player {
#[query(column = "player_id", sql_type = "TEXT", primary_key)]
id: String,
name: String,
age: i64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let pool = PgPool::connect(&database_url).await?;
let players = Player::query(pool);
players.create_table_if_not_exists().await?;
players
.upsert_one(Player {
id: "one".to_string(),
name: "Alice".to_string(),
age: 21,
})
.await?;
let rows = players
.clone()
.where_name(QueryOperator::ILike, "ali%".to_string())
.where_age(QueryOrderingOperator::Gte, 18)
.fetch_many()
.await?;
for row in rows {
println!("{row:?}");
}
Ok(())
}Derive sqlx::FromRow when you want to fetch rows back into the model.
let query = Player::query(pool);This stores the SQLx pool and starts with no filters.
query.create_table_if_not_exists().await?;This creates the backing table from the model fields. A field marked with
#[query(primary_key)] becomes the table primary key.
query.add_one(player).await?;
query.add_many(players).await?;add_one inserts one row. add_many inserts every row in the vector. Passing an
empty vector is a no-op.
query.upsert_one(player).await?;upsert_one inserts the row, or updates the existing row with the same primary
key. The model needs exactly one #[query(primary_key)] field for this method.
query.update_one(player).await?;
query.update_many(players).await?;Updates are matched by primary key. Each non-primary-key field is written back to the row.
As with upserts, the model needs exactly one #[query(primary_key)] field.
Every field gets a by_<field> method for equality:
let query = Player::query(pool)
.by_id("one".to_string())
.by_age(21);Every field also gets a where_<field> method for explicit operators:
let query = Player::query(pool)
.where_name(QueryOperator::Like, "Ali%".to_string())
.where_age(QueryOrderingOperator::Gte, 18);Filters are joined with AND.
Use any when you need an OR group:
let query = Player::query(pool)
.where_name(QueryOperator::ILike, "ali%".to_string())
.any(|q| {
q.where_age(QueryOrderingOperator::Lt, 10)
.where_name(QueryOperator::ILike, "bo%".to_string())
});Filters outside any are still joined with AND. Conditions inside one any
group are joined with OR.
let one = query.fetch_one().await?;
let many = query.fetch_many().await?;fetch_one returns at most one matching row. fetch_many returns all matching
rows.
Both methods require at least one filter. If you call them without filters, they
return PlayerQueryError::NoFilters. This is intentional: the generated API is
biased away from accidental full-table reads.
let matching = query.count().await?;
let any_matching = query.exists().await?;count returns the number of matching rows as an i64. exists returns
whether at least one matching row exists.
Both methods require at least one filter. If you call them without filters, they
return PlayerQueryError::NoFilters, matching the fetch safety behavior.
query.by_id("one".to_string()).delete_one().await?;
query.where_name(QueryOperator::ILike, "bot-%".to_string()).delete_many().await?;delete_one deletes at most one matching row. delete_many deletes all matching
rows.
Both methods require at least one filter. If you call them without filters, they
return PlayerQueryError::NoFilters, matching the fetch safety behavior.
QueryOperator is for strings:
QueryOperator::Eq
QueryOperator::Ne
QueryOperator::Like
QueryOperator::ILikeThese map to =, !=, LIKE, and ILIKE.
QueryOrderingOperator is for numeric fields:
QueryOrderingOperator::Eq
QueryOrderingOperator::Ne
QueryOrderingOperator::Gt
QueryOrderingOperator::Gte
QueryOrderingOperator::Lt
QueryOrderingOperator::LteThese map to =, !=, >, >=, <, and <=.
QueryEqualityOperator is for fields that only support equality checks:
QueryEqualityOperator::Eq
QueryEqualityOperator::NeThese map to = and !=.
Models can live in normal Rust modules.
// src/main.rs
mod models;
use inquiry::QueryOperator;
use models::{Post, User};
let users = User::query(pool.clone());
let posts = Post::query(pool.clone());
users.create_table_if_not_exists().await?;
posts.create_table_if_not_exists().await?;
let alice_posts = posts
.where_user_id(QueryOperator::Eq, "user-1".to_string())
.fetch_many()
.await?;// src/models/mod.rs
use inquiry::Queryable;
#[derive(sqlx::FromRow, Queryable, Debug)]
#[query(table = "users")]
pub struct User {
#[query(primary_key)]
pub id: String,
pub name: String,
}
#[derive(sqlx::FromRow, Queryable, Debug)]
#[query(table = "posts")]
pub struct Post {
#[query(primary_key)]
pub id: String,
pub user_id: String,
pub title: String,
}Post references User by storing user_id. For larger relationships, use the
same approach: keep the foreign key field on the model that points at another
model.
Use #[query(table = "...")] to set the table name:
#[query(table = "players")]
struct Player {
// ...
}If you omit it, the table name defaults to the lowercase struct name.
Use #[query(column = "...")] to set a column name:
#[query(column = "player_id")]
id: String,If you omit it, the column name defaults to the field name.
Use #[query(sql_type = "...")] to set a SQL column type:
#[query(sql_type = "TEXT")]
id: String,This is useful when you want to override the inferred type, or when the field type is not one the macro knows how to infer.
Use #[query(primary_key)] to mark the primary key:
#[query(primary_key)]
id: String,You can combine field attributes:
#[query(column = "player_id", sql_type = "TEXT", primary_key)]
id: String,The macro can infer PostgreSQL column types for these Rust types:
| Rust type | PostgreSQL type |
|---|---|
String |
TEXT |
i16 |
SMALLINT |
i32 |
INTEGER |
i64 |
BIGINT |
bool |
BOOLEAN |
f32 |
REAL |
f64 |
DOUBLE PRECISION |
For other types, add #[query(sql_type = "...")] to the field.
- This crate currently targets SQLx and PostgreSQL.
- The derive macro only supports named struct fields.
- Only one primary key field is supported.
fetch_one,fetch_many,count,exists,delete_one, anddelete_manyrequire at least one configured filter.LIKEandILIKEare available throughQueryOperator; pass the SQL pattern yourself, such as"Ali%"or"%ice".- PostgreSQL does not have native unsigned integer columns. Prefer signed Rust
integer types such as
i64for integer fields.