Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SQL query builder #5

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from

Conversation

cycraig
Copy link

@cycraig cycraig commented Sep 27, 2022

Description

Implements a proof-of-concept SQL query builder. Currently only supports basic SELECT statements with a few conditionals, and uses the sea-query crate to compose and generate SQL for now (can be replaced later once the top-level API is finalised. since it's mostly opaque).

This attempts to match the syntax from #4 (comment).

  • SQL query builder: src/sql/query.rs
  • Table tuples and joining logic: src/sql/table.rs
  • New generated code: see tests/accounts.rs for an example of the new code that is generated for tables. Most of it is boilerplate that users should not interact with directly or need to know about, so it might be best to hide it behind a proc-macro derive to keep things cleaner. That's only possible if we don't need to store extra information beyond the column identifiers (like foreign key constraints), but those could still be represented as attributes for a proc-macro, or generated separately.

Example

  1. Select a subset of columns.
SELECT "username", "email" FROM "accounts"
let select: String = Accounts::query()
    .select(|a| (a.username, a.email))
    .to_string();
  1. Select and filter from multiple tables (join still has to be implemented, see the TODO and problems list).
SELECT "user_id", "username", "password", "email"
FROM "accounts", "access", "examples"
WHERE "username" = 'foo'
  AND ("accounts"."last_login" IS NOT NULL OR "accounts"."created_on" <> '1970-01-01 00:00:00')
  AND ("accounts"."user_id" = "access"."user" AND "access"."role" = 'DomainAdmin')
  AND "accounts"."user_id" = "examples"."id"
LIMIT 1
let query = Accounts::query()
    .from::<Access>()
    .from::<Examples>()
    .filter(|(a, ..)| {
        Sql::eq(a.username, user)
            & (Sql::is_not_null(a.last_login) | Sql::ne(a.created_on, timestamp))
    })
    .filter(|(a, acl, ..)| Sql::equals(a.user_id, acl.table, acl.user) & Sql::eq(acl.role, role))
    .filter(|(a, .., ex)| Sql::equals(a.user_id, ex.table, ex.id))
    .select(|(a, ..)| (a.user_id, a.username, a.password, a.email))
    .limit(1)
    .to_string();

The filters could also be combined into one using & instead of multiple filters.

  1. A fetch function is provided behind the postgres flag to execute the query using the postgres crate:
let rows: Vec<postgres::Row> = Accounts::query()
    .select(|a| (a.username, a.email))
    .fetch(&mut client, &[])
    .unwrap();

Just an example, other functions and feature flags could be added for different runtimes.

  1. SQL conditions perform type checks at compile time:

E.g. trying to compare a string to an integer will throw a compilation error:

let select: String = Accounts::query()
    .filter(|a| Sql::eq(a.email, 42)) 
    .select(|a| (a.user_id, a.username))
    .to_string();
error[E0277]: the trait bound `{integer}: Compatible<instant_models::Field<std::string::String, AccountsIden>>` is not satisfied
   --> tests/accounts.rs:185:38
    |
185 |         .filter(|a| Sql::eq(a.email, 42))
    |                     -------          ^ the trait `Compatible<instant_models::Field<std::string::String, AccountsIden>>` is not implemented for `{integer}`
    |                     |
    |                     required by a bound introduced by this call

TODO Future Work

  • Implement join.
  • Automatically generate ON clauses based on foreign keys for joins.
  • Restrict join clauses at compile-time to tables with foreign keys.
  • Support SELECT * for all columns.
  • Use a derive proc-macro to defer generating field identifier boilerplate (AccountsFields, AccountsIden, impl Table for Accounts, etc.)?
  • Restrict types on SQL comparisons (e.g. eq on a BOOLEAN column only allows bools etc.)
  • Implement all SQL conditionals (eq, ne, like, etc.) .
  • Implement all SQL keywords (DISTINCT, ORDER BY, etc.).
  • Implement prepared statements and parameters.
  • Replace sea-query?

Problems

The big problem currently is being unable to restrict which tables can join each other at compile time, due to how the Sources trait is set up to operate on tuples of tables.

E.g.

  1. Generics don't work without negative trait bounds,
impl<A,B> Join<Accounts> for (A, B)
where A: Join<Accounts> {
    type JOINED = (A, B, Accounts);

since trying to implement the same for B causes a "conflicting trait implementation" compilation error.

impl<A,B> Join<Accounts> for (A, B)
where B: Join<Accounts> {
    type JOINED = (A, B, Accounts);
  1. Implementing the trait over concrete types for each permutation works, but requires a combinatorial explosion of code to be generated manually for each possible table combination that can be joined:
// E.g. Access can be joined to Accounts.
impl Join<Accounts> for (Access, Example) {
    type JOINED = (Access, Example, Accounts);
}

// Every other permutation and combination with Access in it needs to be joinable too.
impl Join<Accounts> for (Example, Access) ...
impl Join<Accounts> for (Access, Other) ...
impl Join<Accounts> for (Other, Access) ...
impl Join<Accounts> for (Acesss, Example, Other) ... // combinations have to be implemented for each tuple size too.

This restriction can be implemented quite easily at runtime, however.

Type of change

Enhancement (a non-breaking change which adds functionality)

How the change has been tested

Added unit tests. See the README.md for instructions.

@djc
Copy link
Contributor

djc commented Sep 29, 2022

Okay, I think we should scope out pretty much all of your TODO items. The one thing that's unclear to me is how you want to create a procedural macro. The idea of this crate was that we would not rely on procedural macros, but just do straight up code generation. I think that's what the tests are doing, right?

@cycraig
Copy link
Author

cycraig commented Sep 29, 2022

Okay, I think we should scope out pretty much all of your TODO items.

Sure. I implemented the rest of the basic comparison operators (<, <=, >, >=, IS, IS NOT). The remainder can be added easily later (LIKE, BETWEEN, etc.). The type restrictions for comparisons are implemented already too.

I also implemented JOIN with the ON condition specified manually for now.
E.g.

SELECT "user_id", "role" 
FROM "accounts" 
JOIN "access" ON "accounts"."user_id" = "access"."user"
Accounts::query()
  .join::<Access, _>(|(a, ac)| Sql::eq(a.user_id, acl.user))
  .select(|(a, acl) (a.user_id, acl.role)|
  .to_string();

Joining foreign keys can be made automatic later on.

The one thing that's unclear to me is how you want to create a procedural macro.

It was just a passing thought so the generated code would be more readable and so the query builder side could be used more easily, independent of the code generation. It would be nice to have a better separation between the two code-wise too, i.e. feature-gate or separate crates. Either way, not a big deal.

The idea of this crate was that we would not rely on procedural macros, but just do straight up code generation. I think that's what the tests are doing, right?

Yes, the code generation/tests just output Rust code as strings.

@cycraig cycraig changed the title [WIP] Add SQL query builder Add SQL query builder Oct 3, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

2 participants