This commit is contained in:
2025-08-11 11:46:26 +02:00
commit a5d2eed7a0
16 changed files with 2624 additions and 0 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

View File

@@ -0,0 +1,30 @@
name: Nix CI
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
on:
- pull_request
jobs:
check:
runs-on: nix-flakes
steps:
- name: Set up attic binary cache
uses: https://git.naxdy.org/Mirror/attic-action@v0.3
with:
endpoint: '${{ vars.BINARY_CACHE_URL }}'
token: '${{ secrets.BINARY_CACHE_AUTH_KEY }}'
cache: '${{ vars.BINARY_CACHE_NAME }}'
- uses: actions/checkout@v4
- name: Fetch flake inputs
run: |
nix flake prefetch-inputs
- name: Run flake checks
run: |
nix flake check -j auto --print-build-logs --keep-going

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
/.direnv
/result
/result-*
/target
# Added by cargo
#
# already existing elements were commented out
#/target

1682
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

32
Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "sqlx-utils"
version.workspace = true
edition.workspace = true
repository.workspace = true
license.workspace = true
[lints]
workspace = true
[workspace]
members = [".", "macros"]
[workspace.package]
version = "0.8.6"
license = "MIT"
edition = "2024"
repository = "https://git.naxdy.org/NaxdyOrg/sqlx-utils"
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -3 }
nursery = { level = "warn", priority = -2 }
unwrap_used = { level = "warn", priority = -1 }
print_stdout = { level = "deny", priority = -1 }
[workspace.dependencies]
sqlx-utils-macros = { version = "0.8.6", path = "macros" }
[dependencies]
sqlx = "0.8.6"
tracing = "0.1.41"
sqlx-utils-macros.workspace = true

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2025 Naxdy
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

75
default.nix Normal file
View File

@@ -0,0 +1,75 @@
{
pkgs,
crane,
}:
let
rustToolchain = pkgs.fenix.stable.withComponents [
"cargo"
"rustc"
"rustfmt"
"rust-std"
"rust-analyzer"
"clippy"
];
# more info on https://crane.dev/API.html
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
craneArgs = {
pname = cargoToml.workspace.package.name or cargoToml.package.name;
version = cargoToml.workspace.package.version or cargoToml.package.version;
src = craneLib.cleanCargoSource ./.;
strictDeps = true;
cargoExtraArgs = "--locked --workspace";
# can add `nativeBuildInputs` or `buildInputs` here
env = {
# print backtrace on compilation failure
RUST_BACKTRACE = "1";
# treat warnings as errors
RUSTFLAGS = "-Dwarnings";
RUSTDOCFLAGS = "-Dwarnings";
};
};
cargoArtifacts = craneLib.buildDepsOnly craneArgs;
craneBuildArgs = craneArgs // {
inherit cargoArtifacts;
};
in
{
package = craneLib.buildPackage (
craneBuildArgs
// {
passthru = {
tests = {
test = craneLib.cargoTest craneBuildArgs;
doc = craneLib.cargoDoc craneBuildArgs;
clippy = craneLib.cargoClippy craneBuildArgs;
};
};
}
);
docs = craneLib.cargoDoc (
craneBuildArgs
// {
# used to disable `--no-deps`, which crane enables by default,
# so we include all packages in the resulting docs, to have fully-functional
# offline docs
cargoDocExtraArgs = "";
}
);
inherit rustToolchain cargoToml;
}

131
flake.lock generated Normal file
View File

@@ -0,0 +1,131 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1754269165,
"narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=",
"owner": "ipetkov",
"repo": "crane",
"rev": "444e81206df3f7d92780680e45858e31d2f07a08",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1754894611,
"narHash": "sha256-TEyTVDhzFyfvPahhi1iAmkopt6fMiTlmn6f278lTdDs=",
"owner": "nix-community",
"repo": "fenix",
"rev": "a01861ebeb4d9c504845e7fb81509b82333ca0aa",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1754725699,
"narHash": "sha256-iAcj9T/Y+3DBy2J0N+yF9XQQQ8IEb5swLFzs23CdP88=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "85dbfc7aaf52ecb755f87e577ddbe6dbbdbc1054",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1754725699,
"narHash": "sha256-iAcj9T/Y+3DBy2J0N+yF9XQQQ8IEb5swLFzs23CdP88=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "85dbfc7aaf52ecb755f87e577ddbe6dbbdbc1054",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1754340878,
"narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "cab778239e705082fe97bb4990e0d24c50924c04",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"fenix": "fenix",
"nixpkgs": "nixpkgs_2",
"treefmt-nix": "treefmt-nix"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1754834452,
"narHash": "sha256-otzv/l7c1rL+eH1cuJnUZVp4DR2dMdEIfhtLxTelIBY=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "4e147e787987fdb1baf081bd5c60bedfb0aabe16",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": "nixpkgs_3"
},
"locked": {
"lastModified": 1754847726,
"narHash": "sha256-2vX8QjO5lRsDbNYvN9hVHXLU6oMl+V/PsmIiJREG4rE=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "7d81f6fb2e19bf84f1c65135d1060d829fae2408",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

120
flake.nix Normal file
View File

@@ -0,0 +1,120 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
fenix.url = "github:nix-community/fenix";
crane.url = "github:ipetkov/crane";
treefmt-nix.url = "github:numtide/treefmt-nix";
};
outputs =
{
self,
nixpkgs,
fenix,
crane,
treefmt-nix,
}:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forEachSupportedSystem =
f:
nixpkgs.lib.genAttrs supportedSystems (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ fenix.overlays.default ];
};
package = pkgs.callPackage ./default.nix { inherit crane; };
treefmtEval = treefmt-nix.lib.evalModule pkgs (
import ./treefmt.nix { inherit (package) rustToolchain cargoToml; }
);
treefmt = treefmtEval.config.build.wrapper;
in
f {
inherit
package
pkgs
system
treefmt
treefmtEval
;
}
);
in
{
devShells = forEachSupportedSystem (
{
pkgs,
treefmt,
system,
package,
...
}:
{
default = self.devShells.${system}.full;
full = pkgs.mkShell {
packages = [
treefmt
];
inputsFrom = [ self.packages.${system}.default ];
};
toolchainOnly = pkgs.mkShell {
nativeBuildInputs = [
package.rustToolchain
];
};
}
);
formatter = forEachSupportedSystem ({ treefmt, ... }: treefmt);
packages = forEachSupportedSystem (
{
package,
...
}:
{
default = package.package;
inherit (package) docs;
}
);
checks = forEachSupportedSystem (
{
pkgs,
treefmtEval,
system,
...
}:
let
testsFrom =
pkg:
pkgs.lib.mapAttrs' (name: value: {
name = "${pkg.pname}-${name}";
inherit value;
}) pkg.passthru.tests;
in
{
treefmt = treefmtEval.config.build.check self;
}
// (testsFrom self.packages.${system}.default)
);
};
}

19
macros/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "sqlx-utils-macros"
version.workspace = true
license.workspace = true
edition.workspace = true
repository.workspace = true
[lib]
proc-macro = true
[dependencies]
darling = "0.21.1"
proc-macro-error2 = "2.0.1"
proc-macro2 = "1.0.96"
quote = "1.0.40"
syn = "2.0.104"
[lints]
workspace = true

8
macros/src/lib.rs Normal file
View File

@@ -0,0 +1,8 @@
use proc_macro::TokenStream;
mod push_to_builder;
#[proc_macro_derive(PushToBuilder)]
pub fn push_to_builder(input: TokenStream) -> TokenStream {
push_to_builder::push_to_builder(input.into()).into()
}

View File

@@ -0,0 +1,57 @@
use darling::{FromDeriveInput, ast::Data};
use proc_macro2::Span;
use proc_macro2::TokenStream;
use quote::quote;
use syn::{DeriveInput, Field, Ident, parse2};
#[derive(FromDeriveInput)]
struct PushToBuilderArgs {
ident: Ident,
data: Data<(), Field>,
}
pub fn push_to_builder(input: TokenStream) -> TokenStream {
let input: DeriveInput = match parse2(input) {
Ok(e) => e,
Err(e) => return e.into_compile_error(),
};
let args = match PushToBuilderArgs::from_derive_input(&input) {
Ok(e) => e,
Err(e) => return e.write_errors(),
};
let struct_name = args.ident;
let push_statements = args
.data
.take_struct()
.map(|e| {
e.into_iter()
.enumerate()
.map(|(idx, e)| {
let ident = e
.ident
.unwrap_or_else(|| Ident::new(&format!("{idx}"), Span::call_site()));
quote! {
::sqlx_utils::builder::PushToBuilder::push_to(self.#ident, builder);
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
quote! {
impl<DB> ::sqlx_utils::builder::PushToBuilder<DB> for #struct_name
where
DB: ::sqlx::Database
{
fn push_to(&self, builder: &mut ::sqlx::QueryBuilder<'_, DB>) {
#(
#push_statements
)*
}
}
}
}

201
src/builder.rs Normal file
View File

@@ -0,0 +1,201 @@
pub mod expr;
use std::{
marker::PhantomData,
ops::{Deref, DerefMut},
sync::Arc,
};
use sqlx::{Database, Decode, Encode, QueryBuilder, Type};
use crate::builder::expr::{BinaryOperand, ListGlue};
pub struct BinaryExpr<L, R, DB, Op>
where
L: PushToBuilder<DB>,
R: PushToBuilder<DB>,
DB: Database,
Op: BinaryOperand,
{
left: L,
right: R,
marker: PhantomData<DB>,
marker_op: PhantomData<Op>,
}
impl<L, R, DB, Op> BinaryExpr<L, R, DB, Op>
where
L: PushToBuilder<DB>,
R: PushToBuilder<DB>,
DB: Database,
Op: BinaryOperand,
{
pub fn new(left: L, _operand: Op, right: R) -> Self {
Self {
left,
right,
marker: PhantomData,
marker_op: PhantomData,
}
}
}
impl<L, R, DB, Op> PushToBuilder<DB> for BinaryExpr<L, R, DB, Op>
where
L: PushToBuilder<DB>,
R: PushToBuilder<DB>,
DB: Database,
Op: BinaryOperand,
{
fn push_to(&self, builder: &mut QueryBuilder<'_, DB>) {
self.left.push_to(builder);
builder.push(Op::EXPR);
self.right.push_to(builder);
}
}
pub struct QueryVariable<T, DB>
where
T: Type<DB> + for<'a> Encode<'a, DB> + for<'a> Decode<'a, DB> + Clone + 'static,
DB: Database,
{
inner: T,
marker: PhantomData<DB>,
}
impl<T, DB> PushToBuilder<DB> for QueryVariable<T, DB>
where
T: Type<DB> + for<'a> Encode<'a, DB> + for<'a> Decode<'a, DB> + Clone + 'static,
DB: Database,
{
fn push_to(&self, builder: &mut QueryBuilder<'_, DB>) {
builder.push_bind(self.inner.clone());
}
}
pub struct BracketsExpr<P, DB>
where
P: PushToBuilder<DB>,
DB: Database,
{
inner: P,
marker: PhantomData<DB>,
}
impl<P, DB> BracketsExpr<P, DB>
where
P: PushToBuilder<DB>,
DB: Database,
{
pub const fn new(inner: P) -> Self {
Self {
inner,
marker: PhantomData,
}
}
}
impl<P, DB> PushToBuilder<DB> for BracketsExpr<P, DB>
where
P: PushToBuilder<DB>,
DB: Database,
{
fn push_to(&self, builder: &mut QueryBuilder<'_, DB>) {
builder.push("(");
self.inner.push_to(builder);
builder.push(")");
}
}
pub struct ExprList<DB, Op>
where
DB: Database,
Op: ListGlue,
{
list: Vec<Arc<dyn PushToBuilder<DB>>>,
marker: PhantomData<Op>,
}
impl<DB, Op> ExprList<DB, Op>
where
DB: Database,
Op: ListGlue,
{
#[must_use]
pub const fn new(list: Vec<Arc<dyn PushToBuilder<DB>>>) -> Self {
Self {
list,
marker: PhantomData,
}
}
}
impl<DB, Op> Deref for ExprList<DB, Op>
where
DB: Database,
Op: ListGlue,
{
type Target = Vec<Arc<dyn PushToBuilder<DB>>>;
fn deref(&self) -> &Self::Target {
&self.list
}
}
impl<DB, Op> DerefMut for ExprList<DB, Op>
where
DB: Database,
Op: ListGlue,
{
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.list
}
}
impl<DB, Op> PushToBuilder<DB> for ExprList<DB, Op>
where
DB: Database,
Op: ListGlue,
{
fn push_to(&self, builder: &mut QueryBuilder<'_, DB>) {
let mut elems = self.list.clone();
if let Some(init) = elems.pop() {
BracketsExpr::new(init).push_to(builder);
}
for e in elems {
builder.push(Op::EXPR);
BracketsExpr::new(e).push_to(builder);
}
}
}
pub trait PushToBuilder<DB>
where
DB: Database,
{
fn push_to(&self, builder: &mut QueryBuilder<'_, DB>);
}
impl<DB> PushToBuilder<DB> for Arc<dyn PushToBuilder<DB>>
where
DB: Database,
{
fn push_to(&self, builder: &mut QueryBuilder<'_, DB>) {
self.deref().push_to(builder);
}
}
impl<DB, T> PushToBuilder<DB> for Option<T>
where
T: PushToBuilder<DB>,
DB: Database,
{
fn push_to(&self, builder: &mut QueryBuilder<'_, DB>) {
match self {
None => {}
Some(e) => e.push_to(builder),
}
}
}

203
src/builder/expr.rs Normal file
View File

@@ -0,0 +1,203 @@
use crate::builder::PushToBuilder;
use crate::builder::expr::sealed::Sealed;
use crate::builder::expr::sealed::SealedBinary;
use crate::builder::expr::sealed::SealedList;
use crate::builder::expr::sealed::SealedWhere;
use sqlx::Database;
use sqlx::QueryBuilder;
macro_rules! impl_push {
($($struct:ident),*) => {
$(
impl<DB> PushToBuilder<DB> for $struct
where
DB: Database,
{
fn push_to(&self, builder: &mut QueryBuilder<'_, DB>) {
builder.push(<Self as SqlOperand>::EXPR);
}
}
)*
};
}
pub trait SqlOperand: Sealed {
const EXPR: &str;
}
pub trait BinaryOperand: SqlOperand + SealedBinary {}
impl<T> BinaryOperand for T where T: SealedBinary + SqlOperand {}
pub trait ListGlue: SqlOperand + SealedList {}
impl<T> ListGlue for T where T: SealedList + SqlOperand {}
pub trait WhereOp: SqlOperand + SealedWhere {}
impl<T> WhereOp for T where T: SealedWhere + SqlOperand {}
pub struct And;
impl SqlOperand for And {
const EXPR: &str = " AND ";
}
pub struct Or;
impl SqlOperand for Or {
const EXPR: &str = " OR ";
}
pub struct Comma;
impl SqlOperand for Comma {
const EXPR: &str = ", ";
}
pub struct Equals;
impl SqlOperand for Equals {
const EXPR: &str = " = ";
}
pub struct Is;
impl SqlOperand for Is {
const EXPR: &str = " IS ";
}
pub struct NotEquals;
impl SqlOperand for NotEquals {
const EXPR: &str = " != ";
}
pub struct IsNot;
impl SqlOperand for IsNot {
const EXPR: &str = " IS NOT ";
}
pub struct Like;
impl SqlOperand for Like {
const EXPR: &str = " LIKE ";
}
pub struct NotLike;
impl SqlOperand for NotLike {
const EXPR: &str = " NOT LIKE ";
}
pub struct Ilike;
impl SqlOperand for Ilike {
const EXPR: &str = " ILIKE ";
}
pub struct NotIlike;
impl SqlOperand for NotIlike {
const EXPR: &str = " NOT ILIKE ";
}
pub struct In;
impl SqlOperand for In {
const EXPR: &str = " IN ";
}
pub struct NotIn;
impl SqlOperand for NotIn {
const EXPR: &str = " NOT IN ";
}
impl_push!(
And, Or, Comma, Equals, Is, NotEquals, IsNot, Like, NotLike, Ilike, NotIlike, In, NotIn
);
#[must_use]
pub const fn get_binary_expr<T: BinaryOperand>() -> &'static str {
<T as SqlOperand>::EXPR
}
#[must_use]
pub const fn get_list_expr<T: ListGlue>() -> &'static str {
<T as SqlOperand>::EXPR
}
#[must_use]
pub const fn get_where_expr<T: WhereOp>() -> &'static str {
<T as SqlOperand>::EXPR
}
mod sealed {
use crate::builder::expr::{
And, Comma, Equals, Ilike, In, Is, IsNot, Like, NotEquals, NotIlike, NotIn, NotLike, Or,
};
pub trait Sealed {}
pub trait SealedBinary {}
pub trait SealedList {}
pub trait SealedWhere {}
impl SealedList for And {}
impl SealedList for Comma {}
impl SealedList for Or {}
impl Sealed for And {}
impl Sealed for Comma {}
impl Sealed for Equals {}
impl Sealed for Ilike {}
impl Sealed for In {}
impl Sealed for Is {}
impl Sealed for IsNot {}
impl Sealed for Like {}
impl Sealed for NotEquals {}
impl Sealed for NotIlike {}
impl Sealed for NotIn {}
impl Sealed for NotLike {}
impl Sealed for Or {}
impl SealedBinary for And {}
impl SealedBinary for Comma {}
impl SealedBinary for Equals {}
impl SealedBinary for Ilike {}
impl SealedBinary for In {}
impl SealedBinary for Is {}
impl SealedBinary for IsNot {}
impl SealedBinary for Like {}
impl SealedBinary for NotEquals {}
impl SealedBinary for NotIlike {}
impl SealedBinary for NotIn {}
impl SealedBinary for NotLike {}
impl SealedBinary for Or {}
impl SealedWhere for Equals {}
impl SealedWhere for Ilike {}
impl SealedWhere for In {}
impl SealedWhere for Is {}
impl SealedWhere for IsNot {}
impl SealedWhere for Like {}
impl SealedWhere for NotEquals {}
impl SealedWhere for NotIlike {}
impl SealedWhere for NotIn {}
impl SealedWhere for NotLike {}
}
#[cfg(test)]
mod test {
use crate::builder::expr::{And, get_binary_expr};
#[test]
fn ensure_cast() {
assert_eq!(get_binary_expr::<And>(), " AND ");
}
}

1
src/lib.rs Normal file
View File

@@ -0,0 +1 @@
pub mod builder;

35
treefmt.nix Normal file
View File

@@ -0,0 +1,35 @@
{ rustToolchain, cargoToml }:
{ pkgs, ... }:
{
# rust
programs.rustfmt = {
enable = true;
package = rustToolchain;
edition = cargoToml.workspace.package.edition or cargoToml.package.edition;
};
# nix
programs.nixfmt.enable = true;
# toml
programs.taplo.enable = true;
# markdown, yaml, etc.
programs.prettier = {
enable = true;
settings = {
trailingComma = "all";
semi = true;
printWidth = 120;
singleQuote = true;
};
};
programs.typos = {
enable = true;
includes = [
"*.rs"
"*.nix"
];
};
}