Skip to content

Commit 937ddf0

Browse files
authored
feat(cargo): split entity-attribute generators into opt-out features (#132)
* #131 feat(cargo): split entity-attribute generators into opt-out features Every entity-attribute group now lives behind its own Cargo feature so users can shrink their build by switching off generators they don't need. All new features ship default-on, so existing projects compile unchanged. New features (default-on, non-breaking): - events — `{Entity}Event` enum, lifecycle event variants - commands — command structs, dispatcher trait (`#[entity(commands)]`, `#[command(...)]`) - hooks — `{Entity}Hooks` trait (manual wiring; #127 tracks auto-invocation) - transactions — `{Entity}TransactionRepo` adapter + deprecated `with_*` builders (`#[entity(transactions)]`) - aggregate_root — `New{Entity}` constructor and transactional `save()` (`#[entity(aggregate_root)]`) - migrations — compile-time `MIGRATION_UP`/`MIGRATION_DOWN` constants (`#[entity(migrations)]`) - projections — projection structs + `find_by_id_<projection>` methods (`#[projection(...)]`) Plumbing: - Each feature in `entity-derive` activates its sibling in `entity-derive-impl`. The facade now declares `entity-derive-impl = { … default-features = false }` so transitive default-on doesn't leak past the user's explicit selection. - In `entity-derive-impl/src/entity.rs`, every gated submodule and its `generate(&entity)` call sits under `#[cfg(feature = "<name>")]`. - New helper `guard_disabled_attribute()` emits a friendly `compile_error!` if the entity attribute is present but the feature is off — much clearer than a missing-method puzzle at the call site. - `streams` now depends on `events` (the NOTIFY payload is an event variant; `streams` without `events` would not link). - Crate-level `#![cfg_attr(any(not(feature = "migrations"), not(feature = "projections")), allow(dead_code, unused_imports))]` keeps minimal builds warning-free without touching individual parser helpers. README feature matrix rewritten with a Default column and a "If you use an entity attribute whose feature is disabled, the macro tells you so" note plus a `default-features = false` example. Bump: - entity-derive-impl: 0.6.2 -> 0.6.3 - entity-derive: 0.8.3 -> 0.8.4 `entity-core` is unchanged. Closes #131 * test(features): cover guard_disabled_attribute helper
1 parent bf6c120 commit 937ddf0

5 files changed

Lines changed: 274 additions & 21 deletions

File tree

README.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,32 @@ entity-derive = { version = "0.8", features = ["postgres", "api"] }
8383

8484
### Feature flags
8585

86-
| Feature | What it does |
87-
|---------|--------------|
88-
| `postgres` *(default)* | Generate `sqlx::PgPool`-backed repository implementations |
89-
| `clickhouse` | Generate ClickHouse-backed repositories *(planned)* |
90-
| `mongodb` | Generate MongoDB-backed repositories *(planned)* |
91-
| `streams` | Generate `{Entity}Subscriber` using Postgres `LISTEN`/`NOTIFY` |
92-
| `api` | Generate HTTP handlers (`axum`) and `utoipa` OpenAPI schemas |
93-
| `validate` | Wire up `validator::Validate` on generated DTOs |
94-
| `tracing` | Wrap every generated async method in `#[tracing::instrument]` carrying `entity` + `op` span fields |
86+
| Feature | Default | What it does |
87+
|---------|:-------:|--------------|
88+
| `postgres` || Generate `sqlx::PgPool`-backed repository implementations |
89+
| `events` || Generate `{Entity}Event` enum (`Created` / `Updated` / `Deleted` variants) |
90+
| `commands` || CQRS command pattern: command structs + dispatcher (`#[entity(commands)]`, `#[command(...)]`) |
91+
| `hooks` || `{Entity}Hooks` trait with before/after lifecycle methods |
92+
| `transactions` || `{Entity}TransactionRepo` adapter + transaction builder helpers (`#[entity(transactions)]`) |
93+
| `aggregate_root` || `New{Entity}` constructor type and transactional `save()` (`#[entity(aggregate_root)]`) |
94+
| `migrations` || Compile-time `MIGRATION_UP` / `MIGRATION_DOWN` SQL constants (`#[entity(migrations)]`) |
95+
| `projections` || Projection structs and `find_by_id_<projection>` lookups (`#[projection(...)]`) |
96+
| `clickhouse` | | Generate ClickHouse-backed repositories *(planned)* |
97+
| `mongodb` | | Generate MongoDB-backed repositories *(planned)* |
98+
| `streams` | | `{Entity}Subscriber` using Postgres `LISTEN`/`NOTIFY` (pulls in `events`) |
99+
| `api` | | Generate HTTP handlers (`axum`) and `utoipa` OpenAPI schemas |
100+
| `validate` | | Wire up `validator::Validate` on generated DTOs |
101+
| `tracing` | | Wrap every generated async method in `#[tracing::instrument]` carrying `entity` + `op` span fields |
102+
103+
Default features cover the full entity-attribute surface so existing projects work without changes. For lean builds, opt out of what you don't need:
104+
105+
```toml
106+
[dependencies]
107+
# Just repositories — no events, hooks, commands, etc.
108+
entity-derive = { version = "0.8", default-features = false, features = ["postgres"] }
109+
```
110+
111+
If you use an entity attribute whose feature is disabled (e.g. `#[entity(commands)]` without `features = ["commands"]`), the macro emits a `compile_error!` at the attribute pointing to the missing feature.
95112

96113
Enable extras alongside the defaults:
97114

crates/entity-derive-impl/Cargo.toml

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
[package]
55
name = "entity-derive-impl"
6-
version = "0.6.2"
6+
version = "0.6.3"
77
edition.workspace = true
88
rust-version.workspace = true
99
authors.workspace = true
@@ -19,7 +19,42 @@ categories = ["development-tools::procedural-macro-helpers"]
1919
proc-macro = true
2020

2121
[features]
22-
default = []
22+
# Default: every entity-attribute generator is on so existing users see no
23+
# regression. Disable selectively via the `entity-derive` facade for
24+
# minimal builds (`default-features = false, features = ["postgres"]`).
25+
default = [
26+
"events",
27+
"commands",
28+
"hooks",
29+
"transactions",
30+
"aggregate_root",
31+
"migrations",
32+
"projections"
33+
]
34+
35+
# Generates `{Entity}Event` enum and lifecycle event helpers. Required
36+
# transitively by `streams` because the NOTIFY payload uses the event type.
37+
events = []
38+
39+
# Generates command structs, the handler trait, and the dispatcher.
40+
commands = []
41+
42+
# Generates `{Entity}Hooks` trait (currently manual-wiring; see #127 for
43+
# auto-invocation plans).
44+
hooks = []
45+
46+
# Generates `{Entity}TransactionRepo` adapter, the (deprecated) `with_*`
47+
# builder methods, and the `ContextExt` accessor trait.
48+
transactions = []
49+
50+
# Generates `New{Entity}` type and the transactional `save()` method.
51+
aggregate_root = []
52+
53+
# Generates `MIGRATION_UP` / `MIGRATION_DOWN` SQL constants.
54+
migrations = []
55+
56+
# Generates projection structs and `find_by_id_<projection>` methods.
57+
projections = []
2358

2459
[dependencies]
2560
syn = { version = "2", features = ["full", "extra-traits", "parsing"] }

crates/entity-derive-impl/src/entity.rs

Lines changed: 153 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,22 +62,29 @@
6262
//! | `impl UserRepository for PgPool` | PostgreSQL implementation |
6363
6464
mod api;
65+
#[cfg(feature = "commands")]
6566
mod commands;
6667
mod dto;
68+
#[cfg(feature = "events")]
6769
mod events;
70+
#[cfg(feature = "hooks")]
6871
mod hooks;
6972
mod insertable;
7073
mod mappers;
74+
#[cfg(feature = "migrations")]
7175
mod migrations;
76+
#[cfg(feature = "aggregate_root")]
7277
pub mod new_entity;
7378
pub mod parse;
7479
mod policy;
80+
#[cfg(feature = "projections")]
7581
mod projection;
7682
mod query;
7783
mod repository;
7884
mod row;
7985
mod sql;
8086
mod streams;
87+
#[cfg(feature = "transactions")]
8188
mod transaction;
8289

8390
use proc_macro::TokenStream;
@@ -98,22 +105,60 @@ pub fn derive(input: TokenStream) -> TokenStream {
98105

99106
fn generate(entity: EntityDef) -> TokenStream {
100107
let dto = dto::generate(&entity);
101-
let projections = projection::generate(&entity);
102108
let query_struct = query::generate(&entity);
103-
let events = events::generate(&entity);
104-
let hooks = hooks::generate(&entity);
105-
let commands = commands::generate(&entity);
106109
let policy = policy::generate(&entity);
107110
let streams = streams::generate(&entity);
108-
let transaction = transaction::generate(&entity);
109111
let api = api::generate(&entity);
110112
let repository = repository::generate(&entity);
111113
let row = row::generate(&entity);
112114
let insertable = insertable::generate(&entity);
113115
let mappers = mappers::generate(&entity);
114-
let new_entity = new_entity::generate(&entity);
115116
let sql = sql::generate(&entity);
117+
118+
// Opt-out generators. Each entity-attribute group is gated behind a
119+
// Cargo feature so users can shrink their build by switching them
120+
// off via `default-features = false`. The macro itself still parses
121+
// every attribute; only the codegen body is skipped when the
122+
// feature is off. `guard_disabled_attribute` emits a friendly
123+
// compile_error if a user enables an attribute whose feature is
124+
// disabled — much clearer than a missing-method error at the call site.
125+
126+
#[cfg(feature = "events")]
127+
let events = events::generate(&entity);
128+
#[cfg(not(feature = "events"))]
129+
let events = guard_disabled_attribute(&entity, "events", entity.has_events());
130+
131+
#[cfg(feature = "hooks")]
132+
let hooks = hooks::generate(&entity);
133+
#[cfg(not(feature = "hooks"))]
134+
let hooks = guard_disabled_attribute(&entity, "hooks", entity.has_hooks());
135+
136+
#[cfg(feature = "commands")]
137+
let commands = commands::generate(&entity);
138+
#[cfg(not(feature = "commands"))]
139+
let commands = guard_disabled_attribute(&entity, "commands", entity.has_commands());
140+
141+
#[cfg(feature = "transactions")]
142+
let transaction = transaction::generate(&entity);
143+
#[cfg(not(feature = "transactions"))]
144+
let transaction = guard_disabled_attribute(&entity, "transactions", entity.has_transactions());
145+
146+
#[cfg(feature = "aggregate_root")]
147+
let new_entity = new_entity::generate(&entity);
148+
#[cfg(not(feature = "aggregate_root"))]
149+
let new_entity =
150+
guard_disabled_attribute(&entity, "aggregate_root", entity.is_aggregate_root());
151+
152+
#[cfg(feature = "migrations")]
116153
let migrations = migrations::generate(&entity);
154+
#[cfg(not(feature = "migrations"))]
155+
let migrations = guard_disabled_attribute(&entity, "migrations", entity.migrations);
156+
157+
#[cfg(feature = "projections")]
158+
let projections = projection::generate(&entity);
159+
#[cfg(not(feature = "projections"))]
160+
let projections =
161+
guard_disabled_attribute(&entity, "projections", !entity.projections.is_empty());
117162

118163
let expanded = quote! {
119164
#dto
@@ -137,3 +182,105 @@ fn generate(entity: EntityDef) -> TokenStream {
137182

138183
expanded.into()
139184
}
185+
186+
/// Emit a `compile_error!` if the user opted into an entity-attribute
187+
/// group whose Cargo feature is currently disabled.
188+
///
189+
/// `feature_name` is the public feature flag name (e.g. `"commands"`).
190+
/// `is_requested` is the boolean from the entity attribute parser
191+
/// (e.g. `entity.has_commands()`). If the attribute is not used, this
192+
/// returns an empty `TokenStream` and nothing is emitted.
193+
#[allow(dead_code)]
194+
fn guard_disabled_attribute(
195+
entity: &EntityDef,
196+
feature_name: &str,
197+
is_requested: bool
198+
) -> proc_macro2::TokenStream {
199+
if !is_requested {
200+
return proc_macro2::TokenStream::new();
201+
}
202+
let entity_name = entity.name();
203+
let msg = format!(
204+
"entity `{entity_name}` uses an attribute that requires the `{feature_name}` feature of \
205+
`entity-derive`, but it is currently disabled. Enable it by adding \
206+
`features = [\"{feature_name}\"]` to your `entity-derive` dependency, or remove the \
207+
corresponding `#[entity(...)]` / `#[command(...)]` / `#[projection(...)]` attribute."
208+
);
209+
quote! { ::core::compile_error!(#msg); }
210+
}
211+
212+
#[cfg(test)]
213+
mod tests {
214+
use syn::parse_quote;
215+
216+
use super::*;
217+
218+
fn parse_minimal_entity() -> EntityDef {
219+
let input: syn::DeriveInput = parse_quote! {
220+
#[entity(table = "users")]
221+
pub struct User {
222+
#[id]
223+
pub id: ::uuid::Uuid
224+
}
225+
};
226+
EntityDef::from_derive_input(&input).expect("minimal entity must parse")
227+
}
228+
229+
#[test]
230+
fn guard_returns_empty_when_attribute_not_requested() {
231+
let entity = parse_minimal_entity();
232+
let tokens = guard_disabled_attribute(&entity, "commands", false);
233+
assert!(
234+
tokens.is_empty(),
235+
"no compile_error must be emitted when the attribute is absent, got: {tokens}"
236+
);
237+
}
238+
239+
#[test]
240+
fn guard_emits_compile_error_when_attribute_requested_without_feature() {
241+
let entity = parse_minimal_entity();
242+
let tokens = guard_disabled_attribute(&entity, "commands", true).to_string();
243+
assert!(
244+
tokens.contains("compile_error"),
245+
"must emit compile_error! token, got: {tokens}"
246+
);
247+
assert!(
248+
tokens.contains("commands"),
249+
"diagnostic must name the missing feature, got: {tokens}"
250+
);
251+
assert!(
252+
tokens.contains("features = "),
253+
"diagnostic must show the user how to enable, got: {tokens}"
254+
);
255+
}
256+
257+
#[test]
258+
fn guard_includes_entity_name_in_diagnostic() {
259+
let entity = parse_minimal_entity();
260+
let tokens = guard_disabled_attribute(&entity, "hooks", true).to_string();
261+
assert!(
262+
tokens.contains("User"),
263+
"diagnostic must name the offending entity, got: {tokens}"
264+
);
265+
}
266+
267+
#[test]
268+
fn guard_message_references_correct_feature_name() {
269+
let entity = parse_minimal_entity();
270+
for feature in [
271+
"events",
272+
"commands",
273+
"hooks",
274+
"transactions",
275+
"aggregate_root",
276+
"migrations",
277+
"projections"
278+
] {
279+
let tokens = guard_disabled_attribute(&entity, feature, true).to_string();
280+
assert!(
281+
tokens.contains(feature),
282+
"diagnostic for `{feature}` must mention it, got: {tokens}"
283+
);
284+
}
285+
}
286+
}

crates/entity-derive-impl/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@
77
html_favicon_url = "https://raw.githubusercontent.com/RAprogramm/entity-derive/main/assets/favicon.ico"
88
)]
99
#![cfg_attr(docsrs, feature(doc_cfg))]
10+
// Several parsing helpers (column DDL, composite indexes, projection
11+
// metadata) are only consumed by the `migrations` and `projections`
12+
// generators. When users opt out of those features, the helpers become
13+
// unused — silence the dead-code lint in those configurations so minimal
14+
// builds stay warning-clean. Default builds (every feature on) keep the
15+
// warning active.
16+
#![cfg_attr(
17+
any(not(feature = "migrations"), not(feature = "projections")),
18+
allow(dead_code, unused_imports)
19+
)]
1020
#![warn(
1121
missing_docs,
1222
rustdoc::missing_crate_level_docs,

crates/entity-derive/Cargo.toml

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
[package]
55
name = "entity-derive"
6-
version = "0.8.3"
6+
version = "0.8.4"
77
edition.workspace = true
88
rust-version.workspace = true
99
authors.workspace = true
@@ -16,21 +16,65 @@ keywords = ["derive", "macro", "entity", "dto", "repository"]
1616
categories = ["development-tools::procedural-macro-helpers", "database"]
1717

1818
[features]
19-
default = ["postgres"]
19+
# Default: bring up every entity-attribute generator so existing users
20+
# see no behavior change. For lean builds use
21+
# `default-features = false, features = ["postgres", "<the ones you need>"]`.
22+
default = [
23+
"postgres",
24+
"events",
25+
"commands",
26+
"hooks",
27+
"transactions",
28+
"aggregate_root",
29+
"migrations",
30+
"projections"
31+
]
32+
33+
# Database dialects
2034
postgres = ["entity-core/postgres"]
2135
clickhouse = ["entity-core/clickhouse"]
2236
mongodb = ["entity-core/mongodb"]
23-
streams = ["entity-core/streams"]
37+
38+
# Real-time streams via Postgres LISTEN/NOTIFY. Pulls in `events`
39+
# because the NOTIFY payload is an event variant.
40+
streams = ["entity-core/streams", "events"]
41+
42+
# HTTP handlers + OpenAPI generation.
2443
api = []
44+
45+
# `validator::Validate` integration on DTOs.
2546
validate = []
47+
48+
# Lifecycle event types (`{Entity}Event::Created` / `Updated` / etc.).
49+
events = ["entity-derive-impl/events"]
50+
51+
# CQRS command pattern: command structs + dispatcher.
52+
commands = ["entity-derive-impl/commands"]
53+
54+
# `{Entity}Hooks` trait. Manual wiring today; auto-invocation tracked in #127.
55+
hooks = ["entity-derive-impl/hooks"]
56+
57+
# `{Entity}TransactionRepo` adapter and the `with_*` builder methods
58+
# (deprecated, slated for removal in 0.8.0 line; see #110).
59+
transactions = ["entity-derive-impl/transactions"]
60+
61+
# Aggregate-root scaffolding: `New{Entity}` type + transactional `save()`.
62+
aggregate_root = ["entity-derive-impl/aggregate_root"]
63+
64+
# Compile-time SQL migration constants (`MIGRATION_UP` / `MIGRATION_DOWN`).
65+
migrations = ["entity-derive-impl/migrations"]
66+
67+
# Partial-view projections and `find_by_id_<projection>` methods.
68+
projections = ["entity-derive-impl/projections"]
69+
2670
# Opt-in: emit `#[tracing::instrument]` on every generated async method.
2771
# Users who enable this must also depend on `tracing` directly so the
2872
# generated `::tracing::instrument` resolves.
2973
tracing = ["entity-core/tracing"]
3074

3175
[dependencies]
3276
entity-core = { path = "../entity-core", version = "0.6" }
33-
entity-derive-impl = { path = "../entity-derive-impl", version = "0.6" }
77+
entity-derive-impl = { path = "../entity-derive-impl", version = "0.6", default-features = false }
3478

3579
[dev-dependencies]
3680
trybuild = "1"

0 commit comments

Comments
 (0)