Skip to content

Commit 02b19ee

Browse files
authored
Add derive DataObject (#421)
1 parent c31a071 commit 02b19ee

File tree

6 files changed

+192
-10
lines changed

6 files changed

+192
-10
lines changed

.mise.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
[tools]
2+
"cargo:cargo-expand" = "latest"
3+
"cargo:pep257" = "latest"
4+
"cargo:timeout-cli" = "latest"
25
editorconfig-checker = "latest"
36
ephemeral-postgres = "latest"
47
osv-scanner = "latest"
58
rust = "stable"
6-
"cargo:pep257" = "latest"
7-
"cargo:timeout-cli" = "latest"
89
typos = "latest"

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,24 @@ struct Post {
4343
}
4444
```
4545

46+
Alternatively, you can use the `#[derive(DataObject)]` syntax:
47+
48+
``` rust
49+
#[derive(DataObject, Default)]
50+
struct Post {
51+
id: AutoPk<i32>,
52+
title: String,
53+
body: String,
54+
published: bool,
55+
likes: i32,
56+
tags: Many<Tag>,
57+
blog: ForeignKey<Blog>,
58+
byline: Option<String>,
59+
}
60+
```
61+
62+
Both syntaxes are functionally equivalent.
63+
4664
An _object_ is an instance of a _model_. An object is created like a
4765
normal struct instance, but must be saved in order to be persisted.
4866

butane/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
77
#![deny(missing_docs)]
88

9-
pub use butane_codegen::{butane_type, dataresult, model, FieldType, PrimaryKeyType};
9+
pub use butane_codegen::{butane_type, dataresult, model, DataObject, FieldType, PrimaryKeyType};
1010
#[cfg(feature = "pg")]
1111
pub use butane_core::custom;
1212
pub use butane_core::fkey::{ForeignKey, ForeignKeyOpsSync};

butane/tests/derive_model.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//! Test that #[derive(DataObject)] works the same as #[model]
2+
3+
use butane::db::ConnectionAsync;
4+
use butane::{query, AutoPk, DataObject};
5+
use butane_test_helper::*;
6+
use butane_test_macros::butane_test;
7+
8+
// Test basic derive(DataObject) usage
9+
#[derive(DataObject, Debug, Clone, PartialEq)]
10+
struct ProductDataObject {
11+
id: AutoPk<i64>,
12+
name: String,
13+
price: i32,
14+
}
15+
16+
impl ProductDataObject {
17+
fn new(name: String, price: i32) -> Self {
18+
ProductDataObject {
19+
id: AutoPk::default(),
20+
name,
21+
price,
22+
}
23+
}
24+
}
25+
26+
// Test derive(DataObject) with custom table name and pk
27+
#[derive(DataObject, Debug, PartialEq)]
28+
#[table = "items"]
29+
struct ItemDataObject {
30+
#[pk]
31+
sku: String,
32+
description: String,
33+
}
34+
35+
#[butane_test]
36+
async fn basic_derive(conn: ConnectionAsync) {
37+
let mut product = ProductDataObject::new("Widget".to_string(), 100);
38+
product.save(&conn).await.unwrap();
39+
40+
let loaded = ProductDataObject::get(&conn, product.id).await.unwrap();
41+
assert_eq!(product, loaded);
42+
assert_eq!(loaded.name, "Widget");
43+
assert_eq!(loaded.price, 100);
44+
}
45+
46+
#[butane_test]
47+
async fn derive_with_custom_table(conn: ConnectionAsync) {
48+
let mut item = ItemDataObject {
49+
sku: "ABC123".to_string(),
50+
description: "Test item".to_string(),
51+
};
52+
item.save(&conn).await.unwrap();
53+
54+
let loaded = ItemDataObject::get(&conn, "ABC123".to_string())
55+
.await
56+
.unwrap();
57+
assert_eq!(item, loaded);
58+
assert_eq!(loaded.description, "Test item");
59+
}
60+
61+
#[butane_test]
62+
async fn derive_query(conn: ConnectionAsync) {
63+
let mut p1 = ProductDataObject::new("Cheap".to_string(), 50);
64+
let mut p2 = ProductDataObject::new("Expensive".to_string(), 200);
65+
p1.save(&conn).await.unwrap();
66+
p2.save(&conn).await.unwrap();
67+
68+
let results = query!(ProductDataObject, price < 100)
69+
.load(&conn)
70+
.await
71+
.unwrap();
72+
assert_eq!(results.len(), 1);
73+
assert_eq!(results[0].name, "Cheap");
74+
}

butane_codegen/src/lib.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,47 @@ pub fn model(_args: TokenStream, input: TokenStream) -> TokenStream {
5959
codegen::model_with_migrations(input.into(), &mut migrations_for_dir()).into()
6060
}
6161

62+
/// Derive macro which marks a struct as being a data model.
63+
///
64+
/// It generates an implementation of [`DataObject`](butane_core::DataObject).
65+
///
66+
/// This is functionally equivalent to the `#[model]` attribute macro and can be
67+
/// used interchangeably. Use `#[derive(DataObject)]` when you prefer the derive syntax
68+
/// or are already using other derive macros on your struct.
69+
///
70+
/// ## Restrictions on model types:
71+
/// 1. The type of each field must implement [`FieldType`] or be [`Many`].
72+
/// 2. There must be a primary key field. This must be either annotated with a `#[pk]` attribute or named `id`.
73+
///
74+
/// ## Helper Attributes
75+
/// * `#[table = "NAME"]` used on the struct to specify the name of the table (defaults to struct name)
76+
/// * `#[pk]` on a field to specify that it is the primary key.
77+
/// * `#[unique]` on a field indicates that the field's value must be unique
78+
/// (perhaps implemented as the SQL UNIQUE constraint by some backends).
79+
/// * `#[default]` should be used on fields added by later migrations to avoid errors on existing objects.
80+
/// Unnecessary if the new field is an `Option<>`
81+
///
82+
/// For example
83+
/// ```ignore
84+
/// #[derive(DataObject, Debug)]
85+
/// #[table = "posts"]
86+
/// pub struct Post {
87+
/// #[pk] // unnecessary if identifier were named id instead
88+
/// pub identifier: AutoPk<i32>,
89+
/// pub title: String,
90+
/// pub content: String,
91+
/// #[default = false]
92+
/// pub published: bool,
93+
/// }
94+
/// ```
95+
///
96+
/// [`FieldType`]: crate::FieldType
97+
/// [`Many`]: butane_core::many::Many
98+
#[proc_macro_derive(DataObject, attributes(table, pk, unique, default, auto))]
99+
pub fn derive_data_object(input: TokenStream) -> TokenStream {
100+
codegen::derive_dataobject_with_migrations(input.into(), &mut migrations_for_dir()).into()
101+
}
102+
62103
/// Attribute macro which generates an implementation of
63104
/// [`DataResult`](butane_core::DataResult). Continuing with our blog
64105
/// post example from [model](macro@model), we could create a `DataResult` with

butane_core/src/codegen/mod.rs

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,35 @@ static PATH_MAPPINGS: Map<&'static str, &'static str> = phf_map! {
8282
const PATH_RESOLVER: PathResolver<&'static Map<&'static str, &'static str>> =
8383
create_static_resolver(&PATH_MAPPINGS, true);
8484

85-
/// Implementation of `#[butane::model]`.
85+
/// Core implementation shared by both `#[model]` attribute macro and `#[derive(DataObject)]` derive macro.
86+
///
87+
/// Generates the `DataObject` trait implementation and field expression helpers.
88+
/// Also writes migration information to disk.
89+
///
90+
/// Returns a tuple of (trait implementations, field expressions).
91+
fn dataobject_impl<M>(
92+
ast_struct: &ItemStruct,
93+
ms: &mut impl MigrationsMut<M = M>,
94+
) -> (TokenStream2, TokenStream2)
95+
where
96+
M: MigrationMut,
97+
{
98+
let config: dbobj::Config = config_from_attributes(ast_struct);
99+
migration::write_table_to_disk(ms, ast_struct, &config).unwrap();
100+
let impltraits = dbobj::impl_dbobject(ast_struct, &config);
101+
let fieldexprs = dbobj::add_fieldexprs(ast_struct, &config);
102+
(impltraits, fieldexprs)
103+
}
104+
105+
/// Implementation of the `#[model]` attribute macro.
106+
///
107+
/// This attribute macro transforms a struct into a database model by:
108+
/// 1. Generating `DataObject` trait implementations
109+
/// 2. Generating field expression helpers
110+
/// 3. Recording migration information
111+
/// 4. Re-emitting the struct definition with helper attributes removed
112+
///
113+
/// The macro accepts helper attributes like `#[table]`, `#[pk]`, `#[unique]`, etc.
86114
pub fn model_with_migrations<M>(
87115
input: TokenStream2,
88116
ms: &mut impl MigrationsMut<M = M>,
@@ -94,17 +122,12 @@ where
94122
// attributes but proc macro attributes can't yet (nor can they
95123
// create field attributes)
96124
let mut ast_struct: ItemStruct = syn::parse2(input).unwrap();
97-
let config: dbobj::Config = config_from_attributes(&ast_struct);
98125

99126
// Filter out our helper attributes
100127
let attrs: Vec<Attribute> = filter_helper_attributes(&ast_struct);
101-
102128
let vis = &ast_struct.vis;
103129

104-
migration::write_table_to_disk(ms, &ast_struct, &config).unwrap();
105-
106-
let impltraits = dbobj::impl_dbobject(&ast_struct, &config);
107-
let fieldexprs = dbobj::add_fieldexprs(&ast_struct, &config);
130+
let (impltraits, fieldexprs) = dataobject_impl(&ast_struct, ms);
108131

109132
let fields: Punctuated<Field, syn::token::Comma> =
110133
match remove_helper_field_attributes(&mut ast_struct.fields) {
@@ -124,6 +147,31 @@ where
124147
)
125148
}
126149

150+
/// Implementation of the `#[derive(DataObject)]` derive macro.
151+
///
152+
/// This derive macro generates the same `DataObject` trait implementations and helpers
153+
/// as the `#[model]` attribute macro, but follows derive macro conventions:
154+
/// - Only generates additional code (trait impls, helpers)
155+
/// - Does NOT re-emit the struct definition
156+
/// - Requires the struct to already exist with all fields defined
157+
///
158+
/// Functionally equivalent to `#[model]` but with derive syntax.
159+
pub fn derive_dataobject_with_migrations<M>(
160+
input: TokenStream2,
161+
ms: &mut impl MigrationsMut<M = M>,
162+
) -> TokenStream2
163+
where
164+
M: MigrationMut,
165+
{
166+
let ast_struct: ItemStruct = syn::parse2(input).unwrap();
167+
let (impltraits, fieldexprs) = dataobject_impl(&ast_struct, ms);
168+
169+
quote!(
170+
#impltraits
171+
#fieldexprs
172+
)
173+
}
174+
127175
/// Implementation of `#[butane::dataresult(<Model>)]`.
128176
pub fn dataresult(args: TokenStream2, input: TokenStream2) -> TokenStream2 {
129177
let dbo: Ident = syn::parse2(args)

0 commit comments

Comments
 (0)