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, 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.
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,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.