diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 022ed2b..74e7714 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 + 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 + diff --git a/Cargo.lock b/Cargo.lock index cb11ff0..1daf531 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1246,7 +1246,9 @@ dependencies = [ [[package]] name = "rquery-orm-macros" -version = "0.1.0" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fa352f2bebc092e6d0ba10acb560d442ac30c53101b64f65308000e4758310" dependencies = [ "proc-macro2", "quote", diff --git a/rquery-orm-macros/Cargo.lock b/rquery-orm-macros/Cargo.lock new file mode 100644 index 0000000..bfe6b38 --- /dev/null +++ b/rquery-orm-macros/Cargo.lock @@ -0,0 +1,47 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rquery-orm-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" diff --git a/rquery-orm-macros/src/lib.rs b/rquery-orm-macros/src/lib.rs index e191894..4befbda 100644 --- a/rquery-orm-macros/src/lib.rs +++ b/rquery-orm-macros/src/lib.rs @@ -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 @@ -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; } + } + , _ => {} + } } } NestedMeta::Meta(Meta::Path(p)) => { @@ -578,5 +604,8 @@ pub fn entity(input: TokenStream) -> TokenStream { #(#key_trait_impls)* }; - TokenStream::from(expanded) + expanded } + +#[cfg(test)] +mod tests; diff --git a/rquery-orm-macros/src/tests.rs b/rquery-orm-macros/src/tests.rs new file mode 100644 index 0000000..0574a06 --- /dev/null +++ b/rquery-orm-macros/src/tests.rs @@ -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, + #[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")); +}