# rest-sql
[](https://github.com/dohrm/rest-sql/actions)
[](https://crates.io/crates/rest-sql)
[](https://docs.rs/rest-sql)
[](https://blog.rust-lang.org/2025/02/20/Rust-1.85.0/)
[](https://github.com/dohrm/rest-sql#wasm-compatibility)
[](https://opensource.org/licenses/MIT)
Parse and validate [RSQL / FIQL](https://github.com/jirutka/rsql-parser) filter queries into a typed AST,
or build filters programmatically — then hand the result to a backend driver.
```
name=like=Chris*;year=gt=1990;genre=in=(Drama,Thriller)
```
This crate is **pure and WASM-compatible** — it has no I/O and no backend dependency.
For compiling to SQL/MongoDB/SurrealQL, see [`rest-sql-drivers`](https://crates.io/crates/rest-sql-drivers).
---
## Parse a query string
```rust
use rest_sql::RestSql;
let rsql = RestSql::new("title=like=Godfather*;year=gt=1970")?;
```
## Build programmatically
Use the `filter` module when the query comes from code rather than a string:
```rust
use rest_sql::{RestSql, filter::{eq, gte, ilike, in_}};
use rest_sql::Ast;
// Simple composition
let ast = ilike("title", "godfather*") & gte("year", 1970i64);
let rsql = RestSql::from_ast(ast)?;
// Conditional construction — None entries are silently skipped
let ast = Ast::try_and_opts([
title_filter.map(|t| ilike("title", t)),
min_year.map(|y| gte("year", y)),
genres.map(|g| in_("genre", g)),
]);
let rsql: Option<RestSql> = ast.map(RestSql::from_ast).transpose()?;
```
`from_ast` runs the same validation as `new` — operator/value compatibility, `=between=` arity, etc.
Use `from_ast_for::<T>()` to also enforce a field allowlist derived from a `#[derive(Deserialize)]` struct.
### `BitAnd` / `BitOr` combinators
`&` and `|` operators flatten adjacent nodes of the same type:
```rust
let a = eq("status", "active") & gte("age", 18i64); // And([eq, gte])
let b = eq("role", "admin") & eq("verified", true); // And([eq, eq])
let merged = a & b; // And([eq, gte, eq, eq]) — flat, not nested
```
---
## Operators
| `==` | `=eq=` | Equal |
| `!=` | `=neq=` | Not equal |
| `<` | `=lt=` | Less than |
| `<=` | `=le=` | Less than or equal |
| `>` | `=gt=` | Greater than |
| `>=` | `=ge=` | Greater than or equal |
| | `=in=` | In list |
| | `=out=` | Not in list |
| | `=between=` | Range (inclusive) |
| | `=null=` | Null / absent |
| | `=notnull=` | Not null / present |
| | `=like=` | Pattern (`*` = any chars, `_` = one char) |
| | `=ilike=` | Case-insensitive pattern |
Logical connectors: `;` / `and` / `AND` = AND, `,` / `or` / `OR` = OR, `(...)` = grouping.
`and` / `or` used as values inside lists are never misinterpreted as connectors.
Value types recognized at parse time: `String`, `Integer`, `Float`, `Boolean`, `Null`, `Date` (`YYYY-MM-DD`), `DateTime` (`YYYY-MM-DDTHH:MM:SSZ`).
---
## Field allowlisting
Reject queries that reference undeclared fields — important when filters come from user input:
```rust
// Explicit list
let rsql = RestSql::new_for_fields("title==Inception;secret==x", &["title", "year"])?;
// → Err: field 'secret' is not allowed
// Derive from a serde struct (feature `serde`)
#[derive(serde::Deserialize)]
struct Movie { title: String, year: i32, rating: f64 }
let rsql = RestSql::new_for::<Movie>("title==Inception;year=gt=2000")?;
```
---
## Field mapping
`FieldMapper` renames logical field names before the AST reaches a driver. Useful for JSONB columns, table aliases, or any naming mismatch:
```rust
use rest_sql::{FieldMapper, RestSql};
use std::borrow::Cow;
struct PrefixMapper(&'static str);
impl FieldMapper for PrefixMapper {
fn map<'a>(&self, field: &'a str) -> Cow<'a, str> {
Cow::Owned(format!("{}.{}", self.0, field))
}
}
let rsql = RestSql::new("title==Inception")?.map_fields(&PrefixMapper("f"));
// AST field is now "f.title"
```
---
## Consumer pattern
The idiomatic way to convert your query structs into a `RestSql`:
```rust
use rest_sql::{RestSql, Ast, filter::{ilike, gte, lte, in_}};
struct FilmQuery {
title: Option<String>,
min_year: Option<i64>,
max_year: Option<i64>,
genres: Option<Vec<String>>,
}
impl TryFrom<FilmQuery> for RestSql {
type Error = rest_sql::RestSqlError;
fn try_from(q: FilmQuery) -> Result<Self, Self::Error> {
let nodes: Vec<Ast> = [
q.title.map(|t| ilike("title", t)),
q.min_year.map(|y| gte("year", y)),
q.max_year.map(|y| lte("year", y)),
q.genres.map(|g| in_("genre", g)),
]
.into_iter()
.flatten()
.collect();
RestSql::from_ast(Ast::try_and(nodes).unwrap_or(Ast::and([gte("year", 0i64)])))
}
}
```
---
## Feature flags
| `serde` | off | Enables `RestSql::new_for::<T>()` — field allowlist from `#[derive(Deserialize)]` |
---
## Implementation
Hand-written lexer + recursive descent grammar — no `nom`, `pest`, or `combine`.
Those frameworks add measurable overhead on short inputs; a typical REST filter is 20–100 bytes
parsed on every request, so zero framework cost matters.
---
## License
MIT — see [LICENSE](../../LICENSE).
Full documentation and examples: [github.com/dohrm/rest-sql](https://github.com/dohrm/rest-sql).