DEV Community

Romans Malinovskis
Romans Malinovskis

Posted on

The "Expressive" protocol

Followed the introduction of 3 query builders (SQL, MongoDB and SurrealDB query dialects), I still felt that my architecture is still uncertain. While implementing builders, I wasn't sure if I should re-use existing expression engines or build a new one or if I should go for Owned or Lazy expressions. Also I wasn't sure how expression engines would interact with multiple databases.

My latest PR is a step to resolve all those issues, introducing a clean way to implement Expression Engines with the Expressive protocol (https://github.com/romaninsh/vantage/pull/58)

Implementing custom Expression Engines

My PR contains a "mock" expression engine, but I intend to re-shape OwnedExpression into a proper implementation of expressive protocol. It is possible though to create new expression engines relatively easily.

  1. Implement necessary traits:
impl Expressive<MyExpr> for MyExpr {
  fn into_expressive() -> IntoExpressive<MyExpr>;
  fn expr(..) -> MyExpr;
} 
impl DataSource<MyExpr> for MyDB {
  async fn execute(expr) -> Value;
  fn defer(expr) -> (async Fn->Value);
}
Enter fullscreen mode Exit fullscreen mode
  1. Implement or derive Debug trait
  2. Implement From for IntoExpressive

The standard way for Rust to cast types:

impl<MyExpr> From<Identifier> for IntoExpressive<MyExpr> {
 ...
}
Enter fullscreen mode Exit fullscreen mode

.. allows Builders to use a wide range of types to to shift weight of implementation into expression engine

Once your expression engine is ready - you can use your Expressive type. Define my_expr! macro to simplify your code.

let my_expr = my_expr!("select {}", Identifier::new("foo"));
Enter fullscreen mode Exit fullscreen mode

Nesting Expressions

Expressive protocol requires implementations to allow nested templates and also support closures for deferred values. If a particular database is incapable of using expressions - nested templates can also be converted into deferred values.

Creating and manipulating expressions must always be sync(), but it may carry some closures to be evaluated later. Obviously if the database can do logic server-side we prefer that.

Here are the examples:

// nesting expressions:
let expr = my_expr!("NOT({})", my_expr!("true OR false"));

// deferred closures:
let expr = my_expr!("{} + 5", async { json!(5) } );
Enter fullscreen mode Exit fullscreen mode

Executing Queries

To execute a query, you need a database driver that implements DataSource<MyExpr>. Yeah! you can support multiple expression engines inside the same datasource.

let db = DataBase::new(..);
let result = db.execute(expr).await;
Enter fullscreen mode Exit fullscreen mode

Calling execute() will evaluate deferred closures first, then will run expression and return result.

There is however, one more feature - ability to defer expressions:

let db1 = DataBase::new(..);
let db2 = GeoDB::new(..);

let subquery = my_expr!(
  "select country_id from countries where ip={}", 
  ip
);

let expr = my_expr!(
  "select * from data where country = {}",
  db2.defer(subquery)  
)

let result = db1.execute(expr).await;
Enter fullscreen mode Exit fullscreen mode

In this scenario, we are still working with a single await, however query would be performed across 2 databases. Result of subquery will be automatically inserted into expression which will be executed too.

As you might have noticed - we can even use different implementation of expressive protocol here.

Lazy expressions and snapshots

Previously I implemented OwnedExpression and LazyExpression, but now I can make them interact since they both will be implementing the same protocol.

Builder implementations may decide which expressions engine to use and would still be compatible with everything around them.

Into<> can even allow you to cast between different expression engines if they are compatible or automatically defer if they are not.

Now I can now go back to the SelectQuery builders and polish them next.

Top comments (0)