Skip to content
Draft
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
26 changes: 26 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ members = [
"butane_test_macros",
"example",
"examples/custom_pg",
"examples/fixed-string",
"examples/newtype",
"examples/getting_started",
"examples/getting_started_async",
Expand Down
5 changes: 5 additions & 0 deletions butane/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ r2d2 = { optional = true, workspace = true }
deadpool = { optional = true, workspace = true }

[dev-dependencies]
arrayvec = "0.7"
butane = { features = ["_auto_delete_dot_butane"], path = "." }
butane_test_helper = { workspace = true, default-features = false, features = ["pg", "sqlite", "turso"] }
butane_test_macros = { workspace = true }
Expand Down Expand Up @@ -107,3 +108,7 @@ required-features = ["async"]
[[test]]
name = "uuid"
required-features = ["async", "uuid"]

[[test]]
name = "array_string"
required-features = ["async"]
209 changes: 209 additions & 0 deletions butane/tests/array_string.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//! Integration tests for ArrayString support in butane

use arrayvec::ArrayString;
use butane::db::{Connection, ConnectionAsync};
use butane::{model, AutoPk};
use butane_test_helper::*;
use butane_test_macros::butane_test;

#[model]
#[derive(Debug, Clone, PartialEq)]
struct User {
id: AutoPk<i64>,
/// Fixed-size username field (max 32 characters)
username: ArrayString<32>,
/// Fixed-size email field (max 255 characters)
email: ArrayString<255>,
/// Optional fixed-size display name (max 64 characters)
display_name: Option<ArrayString<64>>,
}

impl User {
fn new(username: &str, email: &str) -> Self {
let mut user = User {
id: AutoPk::uninitialized(),
username: ArrayString::new(),
email: ArrayString::new(),
display_name: None,
};
user.username.push_str(username);
user.email.push_str(email);
user
}
}

#[model]
#[derive(Debug, Clone, PartialEq)]
struct Product {
/// Use ArrayString as primary key
#[pk]
sku: ArrayString<16>,
name: ArrayString<128>,
description: Option<ArrayString<512>>,
}

impl Product {
fn new(sku: &str, name: &str) -> Self {
let mut product = Product {
sku: ArrayString::new(),
name: ArrayString::new(),
description: None,
};
product.sku.push_str(sku);
product.name.push_str(name);
product
}

fn with_description(mut self, description: &str) -> Self {
let mut desc = ArrayString::new();
desc.push_str(description);
self.description = Some(desc);
self
}
}

#[butane_test]
async fn test_array_string_crud_operations(conn: ConnectionAsync) {
// Create a user with ArrayString fields
let mut user = User::new("alice", "[email protected]");
user.save(&conn).await.expect("Failed to save user");

// Verify the user was saved with correct values
let saved_user = User::get(&conn, user.id).await.expect("Failed to get user");
assert_eq!(saved_user.username.as_str(), "alice");
assert_eq!(saved_user.email.as_str(), "[email protected]");
assert_eq!(saved_user.display_name, None);

// Update the user with a display name
let mut updated_user = saved_user;
updated_user.display_name = Some({
let mut name = ArrayString::new();
name.push_str("Alice Smith");
name
});
updated_user
.save(&conn)
.await
.expect("Failed to update user");

// Verify the update
let final_user = User::get(&conn, updated_user.id)
.await
.expect("Failed to get updated user");
assert_eq!(
final_user.display_name.as_ref().unwrap().as_str(),
"Alice Smith"
);
}

#[butane_test]
async fn test_array_string_as_primary_key(conn: ConnectionAsync) {
// Create products with ArrayString SKU as primary key
let mut product1 = Product::new("ABC123", "Widget A");
let mut product2 = Product::new("XYZ789", "Widget B")
.with_description("A high-quality widget for all your needs");

product1.save(&conn).await.expect("Failed to save product1");
product2.save(&conn).await.expect("Failed to save product2");

// Retrieve by primary key
let retrieved1 = Product::get(&conn, {
let mut sku: ArrayString<16> = ArrayString::new();
sku.push_str("ABC123");
sku
})
.await
.expect("Failed to get product1");

assert_eq!(retrieved1.name.as_str(), "Widget A");
assert_eq!(retrieved1.description, None);

let retrieved2 = Product::get(&conn, {
let mut sku: ArrayString<16> = ArrayString::new();
sku.push_str("XYZ789");
sku
})
.await
.expect("Failed to get product2");

assert_eq!(retrieved2.name.as_str(), "Widget B");
assert_eq!(
retrieved2.description.as_ref().unwrap().as_str(),
"A high-quality widget for all your needs"
);
}

#[butane_test]
async fn test_array_string_length_limits(conn: ConnectionAsync) {
// Test that we can store strings up to the ArrayString capacity
let max_username = "a".repeat(32); // exactly 32 chars
let max_email = "a".repeat(243) + "@example.com"; // 243 + 12 = 255 chars total

let mut user = User {
id: AutoPk::uninitialized(),
username: ArrayString::from(&max_username).expect("Username should fit"),
email: ArrayString::from(&max_email).expect("Email should fit"),
display_name: Some(ArrayString::from(&"a".repeat(64)).expect("Display name should fit")),
};

user.save(&conn)
.await
.expect("Failed to save user with max length fields");

let saved_user = User::get(&conn, user.id)
.await
.expect("Failed to retrieve user");
assert_eq!(saved_user.username.as_str(), max_username);
assert_eq!(saved_user.email.as_str(), max_email);
assert_eq!(
saved_user.display_name.as_ref().unwrap().as_str(),
"a".repeat(64)
);
}

#[butane_test]
async fn test_array_string_empty_values(conn: ConnectionAsync) {
// Test empty ArrayString values
let mut user = User {
id: AutoPk::uninitialized(),
username: ArrayString::new(), // empty
email: ArrayString::from("[email protected]").unwrap(),
display_name: Some(ArrayString::new()), // empty but not null
};

user.save(&conn)
.await
.expect("Failed to save user with empty username");

let saved_user = User::get(&conn, user.id).await.expect("Failed to get user");
assert_eq!(saved_user.username.as_str(), "");
assert_eq!(saved_user.email.as_str(), "[email protected]");
assert_eq!(saved_user.display_name.as_ref().unwrap().as_str(), "");
}

#[butane_test]
async fn test_array_string_special_characters(conn: ConnectionAsync) {
// Test ArrayString with special characters and Unicode
let special_username = "user_123!@#";
let unicode_email = "tëst@émáil.com";
let emoji_display = "User 👤 Name";

let mut user = User {
id: AutoPk::uninitialized(),
username: ArrayString::from(special_username).expect("Should fit"),
email: ArrayString::from(unicode_email).expect("Should fit"),
display_name: Some(ArrayString::from(emoji_display).expect("Should fit")),
};

user.save(&conn)
.await
.expect("Failed to save user with special characters");

let saved_user = User::get(&conn, user.id).await.expect("Failed to get user");
assert_eq!(saved_user.username.as_str(), special_username);
assert_eq!(saved_user.email.as_str(), unicode_email);
assert_eq!(
saved_user.display_name.as_ref().unwrap().as_str(),
emoji_display
);
}
1 change: 1 addition & 0 deletions butane_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tls = ["native-tls", "postgres-native-tls"]
turso = ["async", "dep:turso"]

[dependencies]
arrayvec = "0.7"
Copy link
Owner

Choose a reason for hiding this comment

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

I'd prefer for arrayvec (or whichever we land on here) to be an optional dependency

async-trait = { workspace = true}
bytes = { version = "1.0", optional = true }
cfg-if = { workspace = true }
Expand Down
21 changes: 21 additions & 0 deletions butane_core/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ static PATH_MAPPINGS: Map<&'static str, &'static str> = phf_map! {
"butane::autopk::AutoPk" => "AutoPk",
"butane::fkey::ForeignKey" => "ForeignKey",
"butane::many::Many" => "Many",
"arrayvec::ArrayString" => "ArrayString",
#[cfg(feature = "json")]
"serde_json::Value" => "Value",
#[cfg(feature = "uuid")]
Expand All @@ -64,6 +65,7 @@ static PATH_MAPPINGS: Map<&'static str, &'static str> = phf_map! {
"butane::autopk::AutoPk" => "AutoPk",
"butane::fkey::ForeignKey" => "ForeignKey",
"butane::many::Many" => "Many",
"arrayvec::ArrayString" => "ArrayString",
"chrono::DateTime" => "DateTime",
"chrono::NaiveDate" => "NaiveDate",
"chrono::NaiveDateTime" => "NaiveDateTime",
Expand Down Expand Up @@ -502,6 +504,25 @@ pub fn get_primitive_sql_type(path: &syn::Path) -> Option<DeferredSqlType> {
|| *path == parse_quote!(::std::string::String)
{
return some_known(SqlType::Text);
} else if path.segments.len() == 1
&& path.segments[0].ident == "ArrayString"
&& matches!(
path.segments[0].arguments,
syn::PathArguments::AngleBracketed(_)
)
{
// ArrayString<N> from arrayvec crate - fixed size strings stored as TEXT
return some_known(SqlType::Text);
} else if path.segments.len() == 2
&& path.segments[0].ident == "arrayvec"
&& path.segments[1].ident == "ArrayString"
&& matches!(
path.segments[1].arguments,
syn::PathArguments::AngleBracketed(_)
)
{
// arrayvec::ArrayString<N> - fully qualified, fixed size strings stored as TEXT
return some_known(SqlType::Text);
} else if *path == parse_quote!(Vec<u8>)
|| *path == parse_quote!(std::vec::Vec<u8>)
|| *path == parse_quote!(::std::vec::Vec<u8>)
Expand Down
35 changes: 27 additions & 8 deletions butane_core/src/db/pg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -794,10 +794,17 @@ fn change_column(table: &ATable, old: &AColumn, new: &AColumn) -> Result<String>

if new.is_pk() {
// Drop the old primary key
// Constraint name needs quoting if table name needs quoting
let constraint_name = format!("{}_pkey", tbl_name);
let quoted_constraint = if tbl_name != &quote_reserved_word(tbl_name) {
format!("\"{}\"", constraint_name)
} else {
constraint_name
};
stmts.push(format!(
"ALTER TABLE {} DROP CONSTRAINT IF EXISTS {}_pkey;",
"ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
quote_reserved_word(tbl_name),
tbl_name
quoted_constraint
));

// add the new primary key
Expand All @@ -821,11 +828,17 @@ fn change_column(table: &ATable, old: &AColumn, new: &AColumn) -> Result<String>
));
} else {
// Standard constraint naming scheme
// Constraint name needs quoting if table name needs quoting
let constraint_name = format!("{}_{}_key", tbl_name, old.name());
let quoted_constraint = if tbl_name != &quote_reserved_word(tbl_name) {
format!("\"{}\"", constraint_name)
} else {
constraint_name
};
stmts.push(format!(
"ALTER TABLE {} DROP CONSTRAINT {}_{}_key;",
"ALTER TABLE {} DROP CONSTRAINT {};",
quote_reserved_word(tbl_name),
tbl_name,
&old.name()
quoted_constraint
));
}
}
Expand All @@ -849,11 +862,17 @@ fn change_column(table: &ATable, old: &AColumn, new: &AColumn) -> Result<String>
if old.reference() != new.reference() {
if old.reference().is_some() {
// Drop the old reference
// Constraint name needs quoting if table name needs quoting
let constraint_name = format!("{}_{}_fkey", tbl_name, old.name());
let quoted_constraint = if tbl_name != &quote_reserved_word(tbl_name) {
format!("\"{}\"", constraint_name)
} else {
constraint_name
};
stmts.push(format!(
"ALTER TABLE {} DROP CONSTRAINT {}_{}_fkey;",
"ALTER TABLE {} DROP CONSTRAINT {};",
quote_reserved_word(tbl_name),
tbl_name,
old.name()
quoted_constraint
));
}
if new.reference().is_some() {
Expand Down
Loading
Loading