Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 17 additions & 41 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,55 +2,31 @@ name: CI

on:
push:
branches: [ main, master ]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
services:
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
SA_PASSWORD: "YourStrong!Passw0rd"
ACCEPT_EULA: "Y"
ports:
- 1433:1433
options: >-
--name mssql
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: "YourStrong!Passw0rd"
POSTGRES_DB: tempdb
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install PostgreSQL client
run: sudo apt-get update && sudo apt-get install -y postgresql-client
- name: Wait for MSSQL
- uses: actions-rs/toolchain@v1
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The actions-rs/toolchain action is deprecated. Use dtolnay/rust-toolchain which was already imported in the original code and is the recommended alternative.

Suggested change
- uses: actions-rs/toolchain@v1
- uses: dtolnay/rust-toolchain@v1

Copilot uses AI. Check for mistakes.
with:
toolchain: stable
override: true
- name: Run tests (rquery-orm-macros)
run: |
for i in {1..30}; do
nc -z localhost 1433 && echo "MSSQL is up" && break
echo "Waiting for MSSQL..."
sleep 10
done
- name: Wait for PostgreSQL
cd rquery-orm-macros
cargo test --all-features --verbose
- name: Run tests (rquery-orm)
run: |
for i in {1..30}; do
nc -z localhost 5432 && echo "PostgreSQL is up" && break
echo "Waiting for PostgreSQL..."
sleep 10
done
- name: Setup MSSQL schema
cargo test --all-features --verbose
- name: Install llvm-cov
run: |
docker cp tests/mssql_setup.sql mssql:/tmp/mssql_setup.sql
docker exec mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -C -U sa -P "YourStrong!Passw0rd" -d tempdb -i /tmp/mssql_setup.sql
- name: Setup PostgreSQL schema
rustup component add llvm-tools-preview
cargo install cargo-llvm-cov
- name: Coverage (rquery-orm-macros >= 85%)
run: |
PGPASSWORD=YourStrong!Passw0rd psql -h localhost -U postgres -d tempdb -f tests/pg_setup.sql
- name: Run tests
run: cargo test
- name: Run ignored tests
run: cargo test -- --ignored
cd rquery-orm-macros
cargo llvm-cov --lib --tests --fail-under-lines 85

4 changes: 3 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions rquery-orm-macros/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

99 changes: 64 additions & 35 deletions rquery-orm-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ use syn::{parse_macro_input, Data, DeriveInput, Fields, Lit, Meta, NestedMeta};
#[proc_macro_derive(Entity, attributes(table, column, key, relation))]
pub fn entity(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
entity_impl(input).into()
}

// Core implementation extracted for testing with proc-macro2
pub(crate) fn entity_impl(input: DeriveInput) -> proc_macro2::TokenStream {
let struct_name = input.ident;

// table attributes
Expand Down Expand Up @@ -166,40 +171,61 @@ pub fn entity(input: TokenStream) -> TokenStream {
for nested in list.nested.iter() {
match nested {
NestedMeta::Meta(Meta::NameValue(nv)) => {
if nv.path.is_ident("name") {
if let Lit::Str(s) = &nv.lit { col_name = s.value(); }
} else if nv.path.is_ident("max_length") {
if let Lit::Int(i) = &nv.lit { max_length = i.base10_parse().ok(); }
} else if nv.path.is_ident("min_length") {
if let Lit::Int(i) = &nv.lit { min_length = i.base10_parse().ok(); }
} else if nv.path.is_ident("regex") {
if let Lit::Str(s) = &nv.lit { regex = Some(s.value()); }
} else if nv.path.is_ident("error_max_length") {
if let Lit::Str(s) = &nv.lit { err_max_length = Some(s.value()); }
} else if nv.path.is_ident("error_min_length") {
if let Lit::Str(s) = &nv.lit { err_min_length = Some(s.value()); }
} else if nv.path.is_ident("error_required") {
if let Lit::Str(s) = &nv.lit { err_required = Some(s.value()); }
} else if nv.path.is_ident("error_allow_null") {
if let Lit::Str(s) = &nv.lit { err_allow_null = Some(s.value()); }
} else if nv.path.is_ident("error_allow_empty") {
if let Lit::Str(s) = &nv.lit { err_allow_empty = Some(s.value()); }
} else if nv.path.is_ident("error_regex") {
if let Lit::Str(s) = &nv.lit { err_regex = Some(s.value()); }
} else if nv.path.is_ident("allow_empty") {
if let Lit::Bool(b) = &nv.lit { allow_empty = b.value; }
} else if nv.path.is_ident("required") {
if let Lit::Bool(b) = &nv.lit { required = b.value; }
} else if nv.path.is_ident("allow_null") {
if let Lit::Bool(b) = &nv.lit { allow_null = b.value; }
} else if nv.path.is_ident("ignore_in_update") {
if let Lit::Bool(b) = &nv.lit { ignore_in_update = b.value; }
} else if nv.path.is_ident("ignore_in_insert") {
if let Lit::Bool(b) = &nv.lit { ignore_in_insert = b.value; }
} else if nv.path.is_ident("ignore_in_delete") {
if let Lit::Bool(b) = &nv.lit { ignore_in_delete = b.value; }
} else if nv.path.is_ident("ignore") {
if let Lit::Bool(b) = &nv.lit { ignore = b.value; }
if let Some(ident) = nv.path.get_ident().map(|i| i.to_string()) {
match ident.as_str() {
"name" => {
if let Lit::Str(s) = &nv.lit { col_name = s.value(); }
}
, "max_length" => {
if let Lit::Int(i) = &nv.lit { max_length = i.base10_parse().ok(); }
}
, "min_length" => {
if let Lit::Int(i) = &nv.lit { min_length = i.base10_parse().ok(); }
}
, "regex" => {
if let Lit::Str(s) = &nv.lit { regex = Some(s.value()); }
}
, "error_max_length" => {
if let Lit::Str(s) = &nv.lit { err_max_length = Some(s.value()); }
}
, "error_min_length" => {
if let Lit::Str(s) = &nv.lit { err_min_length = Some(s.value()); }
}
, "error_required" => {
if let Lit::Str(s) = &nv.lit { err_required = Some(s.value()); }
}
, "error_allow_null" => {
if let Lit::Str(s) = &nv.lit { err_allow_null = Some(s.value()); }
}
, "error_allow_empty" => {
if let Lit::Str(s) = &nv.lit { err_allow_empty = Some(s.value()); }
}
, "error_regex" => {
if let Lit::Str(s) = &nv.lit { err_regex = Some(s.value()); }
}
, "allow_empty" => {
if let Lit::Bool(b) = &nv.lit { allow_empty = b.value; }
}
, "required" => {
if let Lit::Bool(b) = &nv.lit { required = b.value; }
}
, "allow_null" => {
if let Lit::Bool(b) = &nv.lit { allow_null = b.value; }
}
, "ignore_in_update" => {
if let Lit::Bool(b) = &nv.lit { ignore_in_update = b.value; }
}
, "ignore_in_insert" => {
if let Lit::Bool(b) = &nv.lit { ignore_in_insert = b.value; }
}
, "ignore_in_delete" => {
if let Lit::Bool(b) = &nv.lit { ignore_in_delete = b.value; }
}
, "ignore" => {
if let Lit::Bool(b) = &nv.lit { ignore = b.value; }
}
, _ => {}
Comment on lines +179 to +227
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra commas before the match arm patterns will cause compilation errors. Remove the leading commas on lines 174, 177, 180, 183, 186, 189, 192, 195, 198, 201, 204, 207, 210, 213, 216, 219, and 222.

Copilot uses AI. Check for mistakes.
}
}
}
NestedMeta::Meta(Meta::Path(p)) => {
Expand Down Expand Up @@ -578,5 +604,8 @@ pub fn entity(input: TokenStream) -> TokenStream {
#(#key_trait_impls)*
};

TokenStream::from(expanded)
expanded
}

#[cfg(test)]
mod tests;
82 changes: 82 additions & 0 deletions rquery-orm-macros/src/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
use quote::quote;
use syn::DeriveInput;

fn gen(input: DeriveInput) -> String {
crate::entity_impl(input).to_string()
}

#[test]
fn generates_entity_impl_and_consts() {
let input: DeriveInput = syn::parse_quote! {
#[table(name = "T1", schema = "dbo")]
struct TestEntity {
#[key(is_identity = true)]
id: i32,
#[column(name = "col_a", required, max_length = 50, min_length = 1, allow_empty, error_required = "e required", error_max_length = "too long", error_min_length = "too short", error_allow_empty = "no empty", error_allow_null = "no null", regex = "^[a-z]+$", error_regex = "bad format", ignore_in_update, ignore_in_insert, ignore_in_delete)]
a: String,
#[column]
b: i32,
#[column(allow_null = true)]
c: Option<i32>,
#[relation(foreign_key = "id", table = "Other", table_number = 2, ignore_in_update, ignore_in_insert)]
rel: i32,
}
};

let s = gen(input);

// Core impls
assert!(s.contains("impl :: rquery_orm :: mapping :: Entity for TestEntity"));
assert!(s.contains("impl :: rquery_orm :: mapping :: Validatable for TestEntity"));
assert!(s.contains("impl :: rquery_orm :: mapping :: Persistable for TestEntity"));
assert!(s.contains("impl :: rquery_orm :: mapping :: FromRowNamed for TestEntity"));
assert!(s.contains("impl :: rquery_orm :: mapping :: FromRowWithPrefix for TestEntity"));

// Table meta
assert!(s.contains("static TABLE_META"));
assert!(s.contains("name : \"T1\""));
assert!(s.contains("schema"));

// Associated consts block
assert!(s.contains("impl TestEntity { pub const TABLE : & 'static str = \"T1\" ;"));
assert!(s.contains("pub const id : & 'static str = \"id\" ;"));
assert!(s.contains("pub const a : & 'static str = \"col_a\" ;"));
}

#[test]
fn builds_sql_fragments() {
let input: DeriveInput = syn::parse_quote! {
#[table(name = "T2")]
struct E2 {
#[key(is_identity = true)] id: i32,
#[column] a: i32,
#[column(ignore_in_update)] b: i32,
#[column(ignore)] c: i32,
#[column(ignore_in_insert)] d: i32,
}
};
let s = gen(input);
// INSERT excludes identity id and ignored fields (format string present)
assert!(s.contains("INSERT INTO {} ("));
// UPDATE has SET and WHERE
assert!(s.contains("UPDATE {} SET"));
assert!(s.contains("WHERE"));
// DELETE has WHERE by key
assert!(s.contains("DELETE FROM {} WHERE"));
}

#[test]
fn validates_string_rules() {
let input: DeriveInput = syn::parse_quote! {
struct EV {
#[key] id: i32,
#[column(required, min_length = 2, max_length = 4, regex = "^[a-z]+$")] name: String,
}
};
let s = gen(input);
// Validation branches should appear
assert!(s.contains("cannot be empty"));
assert!(s.contains("exceeds max length"));
assert!(s.contains("below min length"));
assert!(s.contains("has invalid format"));
}
Loading