Skip to content

dohrm/rest-sql

Repository files navigation

rest-sql

Build Status Crates.io Docs.rs MSRV WASM ready License: MIT

A Rust library that parses RSQL / FIQL filter queries and compiles them into native backend representations — PostgreSQL clauses, MongoDB documents, or SurrealQL strings.

What is RSQL? RSQL is a URI-friendly query language for REST APIs. Instead of inventing a custom filter format per endpoint, you expose a single ?filter= parameter that understands operators like ==, =gt=, =in=, =like= and logical connectors ; (AND) / , (OR). Think SQL WHERE clause, but safe to put in a URL and backend-agnostic.


Quick start

[dependencies]
rest-sql = "0.1"
rest-sql-drivers = { version = "0.1", features = ["tokio-postgres"] }
use rest_sql::RestSql;
use rest_sql_drivers::tokio_postgres::{PgCompiler, PgParams};
use rest_sql_drivers::Driver;
use rest_sql::IdentityMapper;

// Parse from a URL query-string parameter
let rsql = RestSql::new("title=like=Godfather*;year=gt=1970")?;
let (clause, params) = PgCompiler::new(IdentityMapper).compile(&rsql)?;

// clause: "(title LIKE $1 AND year > $2)"
// params: ["Godfather%", 1970]

let query = format!("SELECT * FROM movies WHERE {clause}");
let rows = client.query(&query, &params.iter().map(|p| p.as_ref()).collect::<Vec<_>>()).await?;

Same query, three backends

The real value: one parser, three compilation targets.

(genre==SciFi,genre==Fantasy);year=gt=2000
Backend Output
tokio-postgres ((genre = $1 OR genre = $2) AND year > $3) with params ["SciFi", "Fantasy", 2000]
mongodb { "$and": [{ "$or": [{"genre":"SciFi"},{"genre":"Fantasy"}] }, { "year": {"$gt": 2000} }] }
surrealdb "((genre = 'SciFi' OR genre = 'Fantasy') AND year > 2000)"

Table of contents


Syntax reference

Operators

Syntax (short) Syntax (long) Meaning
== =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= Between two values (inclusive)
=null= Is null / absent
=notnull= Is not null / present
=like= Pattern match (* = any chars, _ = one char)
=ilike= Case-insensitive pattern match

Logical connectors

Syntax Meaning Example
; and AND AND year=gt=1990;rating=ge=8
, or OR OR genre==Drama,genre==Thriller
(...) Grouping (genre==Drama,genre==Horror);rating=gt=7

Standard operator precedence: AND binds tighter than OR. Use parentheses to override. and / or used as values inside lists (=in=(active,and,or)) are never misinterpreted as connectors.

Value types

Values are typed at parse time — no quotes needed for numbers, booleans, or dates.

Type Example Notes
String Alice, "O'Brien", 'hello world' Bare words, or single/double-quoted
Integer 42, -7
Float 8.5, -0.1 Must contain .
Boolean true, false Case-sensitive keywords
Null null
Date 2024-03-15 YYYY-MM-DD
DateTime 2024-03-15T20:30:00Z YYYY-MM-DDTHH:MM:SSZ
List (Drama,Horror,Thriller) Only with =in=, =out=, =between=

Extensions beyond the RSQL/FIQL spec

The original FIQL spec and the jirutka/rsql-parser reference implementation define a minimal set of comparison operators. This library extends that baseline:

Extension Details
Typed literals Date (2024-03-15), DateTime (2024-03-15T20:30:00Z), Bool, Int, Float, Null recognized at lex time — no quotes required
=like= / =ilike= Pattern matching with RSQL wildcard * (multi-char) and _ (single char). Case-insensitive variant =ilike= included
=between= Range filter equivalent to field >= a AND field <= b
=null= / =notnull= Presence check (maps to IS NULL / IS NOT NULL in SQL, NONE in SurrealQL, null match in MongoDB)
=out= Negated list membership (complement of =in=)
and / or keywords and / AND and or / OR accepted as alternatives to ; and , — friendlier for programmatic query builders
Field allowlisting RestSql::new_for_fields() and RestSql::new_for::<T>() reject unknown fields at parse time
FieldMapper trait Translates logical field names to backend column names (JSONB paths, aliases, etc.)

Field allowlisting

Reject queries that reference undeclared fields. Useful when filters are built from user input.

// Explicit allowlist
let rsql = RestSql::new_for_fields("title==Inception;secret==x", &["title", "year"])?;
// → Err: field 'secret' is not allowed

// Derive from a serde struct (requires 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 translates logical names to storage columns before compilation.

use rest_sql::FieldMapper;
use std::borrow::Cow;

// Map logical names to a JSONB column
struct MetaMapper;
impl FieldMapper for MetaMapper {
    fn map<'a>(&self, field: &'a str) -> Cow<'a, str> {
        Cow::Owned(format!("metadata->'{field}'"))
    }
}

The tokio-postgres driver ships a ready-made JsonbTextMapper (uses ->>):

use rest_sql_drivers::tokio_postgres::{PgCompiler, JsonbTextMapper};
use rest_sql_drivers::Driver;

let rsql = RestSql::new("genre==Drama")?;
let (clause, params) = PgCompiler::new(JsonbTextMapper::new("attrs")).compile(&rsql)?;
// clause: "attrs->>'genre' = $1"

Drivers

tokio-postgres

Outputs (String, PgParams) — a parameterized WHERE clause with $1/$2/... placeholders.

use rest_sql_drivers::tokio_postgres::{PgCompiler, PgParams};
use rest_sql_drivers::Driver;
use rest_sql::{IdentityMapper, RestSql};

// Movies directed by someone born after 1950 with rating >= 8
let rsql = RestSql::new("director_birth=gt=1950-01-01;rating=ge=8")?;
let (clause, params) = PgCompiler::new(IdentityMapper).compile(&rsql)?;
// "(director_birth > $1 AND rating >= $2)"

// Actors from specific nationalities
let rsql = RestSql::new("nationality=in=(French,Italian,Spanish)")?;
let (clause, params) = PgCompiler::new(IdentityMapper).compile(&rsql)?;
// "nationality IN ($1, $2, $3)"

// Films released between two dates
let rsql = RestSql::new("released_at=between=(1990-01-01,1999-12-31)")?;
let (clause, params) = PgCompiler::new(IdentityMapper).compile(&rsql)?;
// "released_at BETWEEN $1 AND $2"

// All films without a poster
let rsql = RestSql::new("poster_url=null=true")?;
let (clause, _) = PgCompiler::new(IdentityMapper).compile(&rsql)?;
// "poster_url IS NULL"

// Complex filter: Drama or Horror, rated above 7, released after 2010
let rsql = RestSql::new("(genre==Drama,genre==Horror);rating=gt=7;year=gt=2010")?;
let (clause, params) = PgCompiler::new(IdentityMapper).compile(&rsql)?;
// "((genre = $1 OR genre = $2) AND rating > $3 AND year > $4)"

LIKE wildcard: * in the RSQL pattern is translated to % for PostgreSQL. _ remains _.

name=like=Chris*    →   name LIKE $1   (param: "Chris%")
name=like=Chr_s     →   name LIKE $1   (param: "Chr_s")

mongodb

Outputs bson::Document — a MongoDB filter document.

use rest_sql_drivers::mongodb::MongoCompiler;
use rest_sql_drivers::Driver;
use rest_sql::{IdentityMapper, RestSql};

// Action films rated above 7.5
let rsql = RestSql::new("genre==Action;rating=gt=7.5")?;
let filter = MongoCompiler::new(IdentityMapper).compile(&rsql)?;
// { "$and": [{ "genre": "Action" }, { "rating": { "$gt": 7.5 } }] }

// Actors with unknown nationality
let rsql = RestSql::new("nationality=null=true")?;
let filter = MongoCompiler::new(IdentityMapper).compile(&rsql)?;
// { "nationality": null }  — matches null or absent field

// Films whose title starts with "The"
let rsql = RestSql::new("title=like=The*")?;
let filter = MongoCompiler::new(IdentityMapper).compile(&rsql)?;
// { "title": { "$regex": "^The.*$", "$options": "" } }

// Case-insensitive director name search
let rsql = RestSql::new("director=ilike=kubrick*")?;
let filter = MongoCompiler::new(IdentityMapper).compile(&rsql)?;
// { "director": { "$regex": "^kubrick.*$", "$options": "i" } }

// Horror or Thriller, with a rating and not yet reviewed
let rsql = RestSql::new("(genre==Horror,genre==Thriller);rating=notnull=true;reviewed=null=true")?;
let filter = MongoCompiler::new(IdentityMapper).compile(&rsql)?;

let results = collection.find(filter, None).await?;

LIKE wildcard: *.* (regex), _. (regex). Regex metacharacters are escaped.

Null semantics: field=null=true produces { field: null }, which matches documents where the field is null or absent. To distinguish the two cases, use the MongoDB driver directly.


surrealdb

Outputs a String — an inline SurrealQL WHERE clause with values embedded as literals.

use rest_sql_drivers::surrealdb::SurrealCompiler;
use rest_sql_drivers::Driver;
use rest_sql::{IdentityMapper, RestSql};

// Sci-Fi or Fantasy films after 2000
let rsql = RestSql::new("(genre==SciFi,genre==Fantasy);year=gt=2000")?;
let clause = SurrealCompiler::new(IdentityMapper).compile(&rsql)?;
// "((genre = 'SciFi' OR genre = 'Fantasy') AND year > 2000)"

let query = format!("SELECT * FROM movie WHERE {clause}");
db.query(query).await?;

// Actors still active, born after a specific date
let rsql = RestSql::new("active==true;birth_date=gt=1970-01-01")?;
let clause = SurrealCompiler::new(IdentityMapper).compile(&rsql)?;
// "(active = true AND birth_date > d'1970-01-01')"

// Records with no awards
let rsql = RestSql::new("awards=null=true")?;
let clause = SurrealCompiler::new(IdentityMapper).compile(&rsql)?;
// "awards = NONE"

// Films not in a specific list of genres
let rsql = RestSql::new("genre=out=(Documentary,Animation)")?;
let clause = SurrealCompiler::new(IdentityMapper).compile(&rsql)?;
// "genre NOT IN ['Documentary', 'Animation']"

LIKE wildcard: *.* (regex), _.. Compiled to string::matches(field, 'pattern').

Null semantics: maps to NONE (SurrealQL equivalent of SQL NULL / absent value).

Date/DateTime: emitted as d'YYYY-MM-DD' and d'YYYY-MM-DDTHH:MM:SSZ' SurrealQL literals.


WASM compatibility

Crate / feature WASM-safe Notes
rest-sql Yes Pure parsing, no I/O
rest-sql-drivers + mongodb Yes bson transformation is pure
rest-sql-drivers + surrealdb Yes String transformation is pure
rest-sql-drivers + tokio-postgres No Requires tokio TCP networking

For WASM targets, enable only the mongodb feature (and/or surrealdb). Do not enable tokio-postgres.


Feature flags

rest-sql

Feature Default Description
serde off Enables RestSql::new_for::<T>() — field allowlist from #[derive(Deserialize)] structs

rest-sql-drivers

Feature Default Description
tokio-postgres off PostgreSQL driver via tokio-postgres. Not WASM-compatible.
mongodb off MongoDB driver via bson. WASM-compatible.
surrealdb off SurrealDB driver (inline SurrealQL). WASM-compatible.

Implementation

The parser is a hand-written lexer + recursive descent grammar with no framework dependency. nom, pest, and combine were evaluated and ruled out — their overhead is measurable on short filter strings, which is the dominant use case (a typical REST API filter is 20–100 bytes, parsed on every request).


License

MIT — see LICENSE.

About

A Rust library that parses [RSQL / FIQL](https://github.com/jirutka/rsql-parser) filter queries and compiles them into native backend representations

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages