diff --git a/Cargo.lock b/Cargo.lock index e4054528..831642bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,6 +136,12 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "assert_cmd" version = "2.0.17" @@ -269,6 +275,7 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" name = "butane" version = "0.8.1" dependencies = [ + "arrayvec", "butane", "butane_codegen", "butane_core", @@ -332,6 +339,7 @@ dependencies = [ name = "butane_core" version = "0.8.1" dependencies = [ + "arrayvec", "assert_matches", "async-trait", "butane_test_helper", @@ -950,6 +958,24 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +[[package]] +name = "fixed-string" +version = "0.1.0" +dependencies = [ + "arrayvec", + "butane", + "butane_cli", + "butane_core", + "butane_test_helper", + "butane_test_macros", + "cfg-if", + "env_logger", + "log", + "paste", + "test-log", + "tokio", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index bbf7920f..0e07ffe8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "butane_test_macros", "example", "examples/custom_pg", + "examples/fixed-string", "examples/newtype", "examples/getting_started", "examples/getting_started_async", diff --git a/butane/Cargo.toml b/butane/Cargo.toml index 20dcdc8d..b6c906a8 100644 --- a/butane/Cargo.toml +++ b/butane/Cargo.toml @@ -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 } @@ -107,3 +108,7 @@ required-features = ["async"] [[test]] name = "uuid" required-features = ["async", "uuid"] + +[[test]] +name = "array_string" +required-features = ["async"] diff --git a/butane/tests/array_string.rs b/butane/tests/array_string.rs new file mode 100644 index 00000000..4c88c679 --- /dev/null +++ b/butane/tests/array_string.rs @@ -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, + /// 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>, +} + +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>, +} + +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", "alice@example.com"); + 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(), "alice@example.com"); + 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("empty@example.com").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(), "empty@example.com"); + 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 + ); +} diff --git a/butane_core/Cargo.toml b/butane_core/Cargo.toml index 7e67ee09..abb08c01 100644 --- a/butane_core/Cargo.toml +++ b/butane_core/Cargo.toml @@ -24,6 +24,7 @@ tls = ["native-tls", "postgres-native-tls"] turso = ["async", "dep:turso"] [dependencies] +arrayvec = "0.7" async-trait = { workspace = true} bytes = { version = "1.0", optional = true } cfg-if = { workspace = true } diff --git a/butane_core/src/codegen/mod.rs b/butane_core/src/codegen/mod.rs index e3ce4b4a..a5e03eb0 100644 --- a/butane_core/src/codegen/mod.rs +++ b/butane_core/src/codegen/mod.rs @@ -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")] @@ -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", @@ -502,6 +504,25 @@ pub fn get_primitive_sql_type(path: &syn::Path) -> Option { || *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 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 - fully qualified, fixed size strings stored as TEXT + return some_known(SqlType::Text); } else if *path == parse_quote!(Vec) || *path == parse_quote!(std::vec::Vec) || *path == parse_quote!(::std::vec::Vec) diff --git a/butane_core/src/db/pg.rs b/butane_core/src/db/pg.rs index 740dae40..c3f654cf 100644 --- a/butane_core/src/db/pg.rs +++ b/butane_core/src/db/pg.rs @@ -794,10 +794,17 @@ fn change_column(table: &ATable, old: &AColumn, new: &AColumn) -> Result 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 != "e_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 @@ -821,11 +828,17 @@ fn change_column(table: &ATable, old: &AColumn, new: &AColumn) -> Result )); } 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 != "e_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 )); } } @@ -849,11 +862,17 @@ fn change_column(table: &ATable, old: &AColumn, new: &AColumn) -> Result 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 != "e_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() { diff --git a/butane_core/src/sqlval.rs b/butane_core/src/sqlval.rs index 605cb7f8..d2bd2e04 100644 --- a/butane_core/src/sqlval.rs +++ b/butane_core/src/sqlval.rs @@ -7,6 +7,7 @@ use std::borrow::Cow; use std::collections::{BTreeMap, HashMap}; use std::fmt; +use arrayvec::ArrayString; #[cfg(feature = "datetime")] use chrono::{ naive::{NaiveDate, NaiveDateTime}, @@ -444,6 +445,46 @@ impl FieldType for String { } impl PrimaryKeyType for String {} +// ArrayString implementations +impl FromSql for ArrayString { + fn from_sql_ref(valref: SqlValRef) -> Result { + if let SqlValRef::Text(val) = valref { + ArrayString::from(val).map_err(|_| { + crate::Error::CannotConvertSqlVal(SqlType::Text, SqlVal::Text(val.to_string())) + }) + } else { + sql_conv_err!(valref, Text) + } + } + fn from_sql(val: SqlVal) -> Result { + match val { + SqlVal::Text(text_val) => ArrayString::from(&text_val).map_err(|_| { + crate::Error::CannotConvertSqlVal(SqlType::Text, SqlVal::Text(text_val.clone())) + }), + _ => sql_conv_err!(val, Text), + } + } +} + +impl ToSql for ArrayString { + fn to_sql(&self) -> SqlVal { + SqlVal::Text(self.to_string()) + } + fn to_sql_ref(&self) -> SqlValRef<'_> { + SqlValRef::Text(self.as_str()) + } + fn into_sql(self) -> SqlVal { + SqlVal::Text(self.to_string()) + } +} + +impl FieldType for ArrayString { + const SQLTYPE: SqlType = SqlType::Text; + type RefType = str; +} + +impl PrimaryKeyType for ArrayString {} + impl FromSql for Vec { fn from_sql_ref(valref: SqlValRef) -> Result { if let SqlValRef::Blob(val) = valref { diff --git a/butane_core/tests/array_string.rs b/butane_core/tests/array_string.rs new file mode 100644 index 00000000..5db82e92 --- /dev/null +++ b/butane_core/tests/array_string.rs @@ -0,0 +1,167 @@ +//! Tests for ArrayString support in butane_core + +use arrayvec::ArrayString; +use butane_core::{Error::CannotConvertSqlVal, FromSql, SqlType, SqlVal, SqlValRef, ToSql}; + +#[test] +fn array_string_to_sql() { + let mut array_str = ArrayString::<32>::new(); + array_str.push_str("hello world"); + + let sql_val = array_str.to_sql(); + assert_eq!(sql_val, SqlVal::Text("hello world".to_string())); +} + +#[test] +fn array_string_to_sql_ref() { + let mut array_str = ArrayString::<16>::new(); + array_str.push_str("test"); + + let sql_val_ref = array_str.to_sql_ref(); + if let SqlValRef::Text(text) = sql_val_ref { + assert_eq!(text, "test"); + } else { + panic!("Expected SqlValRef::Text"); + } +} + +#[test] +fn array_string_into_sql() { + let mut array_str = ArrayString::<64>::new(); + array_str.push_str("into test"); + + let sql_val = array_str.into_sql(); + assert_eq!(sql_val, SqlVal::Text("into test".to_string())); +} + +#[test] +fn array_string_from_sql_ref() { + let sql_val_ref = SqlValRef::Text("from sql ref"); + let array_str: ArrayString<32> = ArrayString::from_sql_ref(sql_val_ref).unwrap(); + assert_eq!(array_str.as_str(), "from sql ref"); +} + +#[test] +fn array_string_from_sql() { + let sql_val = SqlVal::Text("from sql".to_string()); + let array_str: ArrayString<16> = ArrayString::from_sql(sql_val).unwrap(); + assert_eq!(array_str.as_str(), "from sql"); +} + +#[test] +fn array_string_from_sql_too_long() { + let long_text = "this string is definitely longer than 8 characters"; + let sql_val = SqlVal::Text(long_text.to_string()); + + // Should fail when the string is too long for the ArrayString capacity + let result: Result, _> = ArrayString::from_sql(sql_val); + assert!(result.is_err()); + + if let Err(CannotConvertSqlVal(SqlType::Text, _)) = result { + // Expected error type + } else { + panic!("Expected CannotConvertSqlVal error"); + } +} + +#[test] +fn array_string_from_sql_ref_too_long() { + let long_text = "this string is too long for a 4 character array"; + let sql_val_ref = SqlValRef::Text(long_text); + + // Should fail when the string is too long for the ArrayString capacity + let result: Result, _> = ArrayString::from_sql_ref(sql_val_ref); + assert!(result.is_err()); + + if let Err(CannotConvertSqlVal(SqlType::Text, _)) = result { + // Expected error type + } else { + panic!("Expected CannotConvertSqlVal error"); + } +} + +#[test] +fn array_string_from_wrong_sql_type() { + let sql_val = SqlVal::Int(42); + let result: Result, _> = ArrayString::from_sql(sql_val); + assert!(result.is_err()); + + let sql_val_ref = SqlValRef::Bool(true); + let result: Result, _> = ArrayString::from_sql_ref(sql_val_ref); + assert!(result.is_err()); +} + +#[test] +fn array_string_field_type() { + use butane_core::FieldType; + + // Test that ArrayString implements FieldType correctly + assert_eq!( as FieldType>::SQLTYPE, SqlType::Text); + assert_eq!( as FieldType>::SQLTYPE, SqlType::Text); +} + +#[test] +fn array_string_primary_key_type() { + use butane_core::PrimaryKeyType; + + let mut pk1 = ArrayString::<16>::new(); + pk1.push_str("pk1"); + + let mut pk2 = ArrayString::<16>::new(); + pk2.push_str("pk2"); + + // Test that ArrayString can be used as a primary key + assert!(pk1.is_valid()); + assert!(pk2.is_valid()); + assert_ne!(pk1, pk2); + + // Test cloning (required for PrimaryKeyType) + let pk1_clone = pk1.clone(); + assert_eq!(pk1, pk1_clone); +} + +#[test] +fn array_string_serialization() { + let mut array_str = ArrayString::<32>::new(); + array_str.push_str("serialize test"); + + let sql_val = array_str.to_sql(); + let serialized = serde_json::to_string(&sql_val).unwrap(); + assert_eq!(serialized, "{\"Text\":\"serialize test\"}"); + + // Test deserialization + let deserialized: SqlVal = serde_json::from_str(&serialized).unwrap(); + if let SqlVal::Text(text) = deserialized { + assert_eq!(text, "serialize test"); + } else { + panic!("Expected SqlVal::Text"); + } +} + +#[test] +fn array_string_empty() { + let empty_str = ArrayString::<10>::new(); + + let sql_val = empty_str.to_sql(); + assert_eq!(sql_val, SqlVal::Text("".to_string())); + + let sql_val_ref = empty_str.to_sql_ref(); + if let SqlValRef::Text(text) = sql_val_ref { + assert_eq!(text, ""); + } else { + panic!("Expected SqlValRef::Text"); + } +} + +#[test] +fn array_string_max_capacity() { + let mut array_str = ArrayString::<5>::new(); + array_str.push_str("12345"); // Exactly at capacity + + let sql_val = array_str.to_sql(); + assert_eq!(sql_val, SqlVal::Text("12345".to_string())); + + // Test round-trip + let recovered: ArrayString<5> = ArrayString::from_sql(sql_val).unwrap(); + assert_eq!(recovered.as_str(), "12345"); +} diff --git a/butane_core/tests/codegen.rs b/butane_core/tests/codegen.rs index d3d657ba..f07983ea 100644 --- a/butane_core/tests/codegen.rs +++ b/butane_core/tests/codegen.rs @@ -228,3 +228,112 @@ fn r_hash_struct_name() { assert_eq!(table.columns[0].name(), "id"); assert_eq!(table.columns[1].name(), "foo"); } + +#[test] +fn array_string_primitive_sql_type() { + // Test basic ArrayString type + let path: syn::Path = syn::parse_quote!(ArrayString<32>); + let rv = get_primitive_sql_type(&path).unwrap(); + if let DeferredSqlType::KnownId(TypeIdentifier::Ty(sql_type)) = rv { + assert_eq!(sql_type, SqlType::Text); + } else { + panic!( + "Expected ArrayString<32> to map to SqlType::Text, got: {:?}", + rv + ); + } + + // Test fully qualified ArrayString type + let path: syn::Path = syn::parse_quote!(arrayvec::ArrayString<64>); + let rv = get_primitive_sql_type(&path).unwrap(); + if let DeferredSqlType::KnownId(TypeIdentifier::Ty(sql_type)) = rv { + assert_eq!(sql_type, SqlType::Text); + } else { + panic!( + "Expected arrayvec::ArrayString<64> to map to SqlType::Text, got: {:?}", + rv + ); + } +} + +#[test] +fn array_string_deferred_sql_type() { + // Test basic ArrayString type through deferred resolver + let path: syn::Path = syn::parse_quote!(ArrayString<16>); + let rv = get_deferred_sql_type(&path); + if let DeferredSqlType::KnownId(TypeIdentifier::Ty(sql_type)) = rv { + assert_eq!(sql_type, SqlType::Text); + } else { + panic!( + "Expected ArrayString<16> to map to SqlType::Text, got: {:?}", + rv + ); + } + + // Test Option> + let path: syn::Path = syn::parse_quote!(Option>); + let rv = get_deferred_sql_type(&path); + if let DeferredSqlType::KnownId(TypeIdentifier::Ty(sql_type)) = rv { + assert_eq!(sql_type, SqlType::Text); + } else { + panic!( + "Expected Option> to map to SqlType::Text, got: {:?}", + rv + ); + } + + // Test fully qualified with Option + let path: syn::Path = syn::parse_quote!(Option>); + let rv = get_deferred_sql_type(&path); + if let DeferredSqlType::KnownId(TypeIdentifier::Ty(sql_type)) = rv { + assert_eq!(sql_type, SqlType::Text); + } else { + panic!( + "Expected Option> to map to SqlType::Text, got: {:?}", + rv + ); + } +} + +#[test] +fn array_string_model_generation() { + let mut migrations = MemMigrations::new(); + + let item: syn::ItemStruct = parse_quote! { + #[model] + pub struct FixedStringModel { + id: u32, + name: ArrayString<32>, + description: Option>, + qualified_name: arrayvec::ArrayString<64>, + } + }; + + let tokens = item.to_token_stream(); + let _model = model_with_migrations(tokens, &mut migrations); + let migration = migrations.current(); + let adb = migration.db().unwrap(); + + // Try common table name variants + let table = adb + .get_table("fixed_string_model") + .or_else(|| adb.get_table("fixedstringmodel")) + .or_else(|| adb.get_table("FixedStringModel")) + .expect("Table should exist with some name variant"); + + // Verify columns exist and have correct names + assert_eq!(table.columns.len(), 4); + assert_eq!(table.columns[0].name(), "id"); + assert_eq!(table.columns[1].name(), "name"); + assert_eq!(table.columns[2].name(), "description"); + assert_eq!(table.columns[3].name(), "qualified_name"); + + // All columns should be resolvable to some type + // (The detailed type checking is done in the primitive_sql_type tests) + for (i, column) in table.columns.iter().enumerate() { + match column.typeid() { + Ok(_) => {} // Good, the type was resolved + Err(_) => panic!("Column {} ({}) type was not resolved", i, column.name()), + } + } +} diff --git a/examples/fixed-string/.butane/clistate.json b/examples/fixed-string/.butane/clistate.json new file mode 100644 index 00000000..a8f08861 --- /dev/null +++ b/examples/fixed-string/.butane/clistate.json @@ -0,0 +1,3 @@ +{ + "embedded": true +} diff --git a/examples/fixed-string/.butane/migrations/.gitignore b/examples/fixed-string/.butane/migrations/.gitignore new file mode 100644 index 00000000..4b67b6a0 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/.gitignore @@ -0,0 +1 @@ +lock diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/Config.table b/examples/fixed-string/.butane/migrations/20251031_014308910_init/Config.table new file mode 100644 index 00000000..1d1a5381 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/Config.table @@ -0,0 +1,44 @@ +{ + "name": "Config", + "columns": [ + { + "name": "key", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "value", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "description", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] +} diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/Order.table b/examples/fixed-string/.butane/migrations/20251031_014308910_init/Order.table new file mode 100644 index 00000000..e4653177 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/Order.table @@ -0,0 +1,95 @@ +{ + "name": "Order", + "columns": [ + { + "name": "id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": true, + "auto": true, + "unique": false, + "default": null + }, + { + "name": "order_number", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "user", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null, + "reference": { + "Literal": { + "table_name": "User", + "column_name": "id" + } + } + }, + { + "name": "product", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null, + "reference": { + "Literal": { + "table_name": "Product", + "column_name": "sku" + } + } + }, + { + "name": "quantity", + "sqltype": { + "KnownId": { + "Ty": "Int" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] +} diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/Product.table b/examples/fixed-string/.butane/migrations/20251031_014308910_init/Product.table new file mode 100644 index 00000000..6b9dbf4f --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/Product.table @@ -0,0 +1,70 @@ +{ + "name": "Product", + "columns": [ + { + "name": "sku", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "name", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "category", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "price_cents", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "in_stock", + "sqltype": { + "KnownId": { + "Ty": "Bool" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] +} diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/User.table b/examples/fixed-string/.butane/migrations/20251031_014308910_init/User.table new file mode 100644 index 00000000..23756973 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/User.table @@ -0,0 +1,70 @@ +{ + "name": "User", + "columns": [ + { + "name": "id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": true, + "auto": true, + "unique": false, + "default": null + }, + { + "name": "username", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "email", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "display_name", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] +} diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/info.json b/examples/fixed-string/.butane/migrations/20251031_014308910_init/info.json new file mode 100644 index 00000000..93ea5a55 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/info.json @@ -0,0 +1,7 @@ +{ + "backends": [ + "sqlite", + "pg", + "turso" + ] +} diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/pg_down.sql b/examples/fixed-string/.butane/migrations/20251031_014308910_init/pg_down.sql new file mode 100644 index 00000000..dc265538 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/pg_down.sql @@ -0,0 +1,6 @@ +ALTER TABLE "Order" DROP CONSTRAINT "Order_user_fkey"; +ALTER TABLE "Order" DROP CONSTRAINT "Order_product_fkey"; +DROP TABLE Config; +DROP TABLE "Order"; +DROP TABLE Product; +DROP TABLE "User"; diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/pg_up.sql b/examples/fixed-string/.butane/migrations/20251031_014308910_init/pg_up.sql new file mode 100644 index 00000000..e145a712 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/pg_up.sql @@ -0,0 +1,32 @@ +CREATE TABLE Config ( +"key" TEXT NOT NULL PRIMARY KEY, +"value" TEXT NOT NULL, +description TEXT +); +CREATE TABLE "Order" ( +"id" BIGSERIAL NOT NULL PRIMARY KEY, +order_number TEXT NOT NULL, +"user" BIGINT NOT NULL, +product TEXT NOT NULL, +quantity INTEGER NOT NULL, +"status" TEXT NOT NULL +); +CREATE TABLE Product ( +sku TEXT NOT NULL PRIMARY KEY, +"name" TEXT NOT NULL, +category TEXT NOT NULL, +price_cents BIGINT NOT NULL, +in_stock BOOLEAN NOT NULL +); +CREATE TABLE "User" ( +"id" BIGSERIAL NOT NULL PRIMARY KEY, +username TEXT NOT NULL, +email TEXT NOT NULL, +display_name TEXT, +"status" TEXT NOT NULL +); +ALTER TABLE "Order" ADD FOREIGN KEY ("user") REFERENCES "User"("id"); +ALTER TABLE "Order" ADD FOREIGN KEY (product) REFERENCES Product(sku); +CREATE TABLE IF NOT EXISTS butane_migrations ( +"name" TEXT NOT NULL PRIMARY KEY +); diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/sqlite_down.sql b/examples/fixed-string/.butane/migrations/20251031_014308910_init/sqlite_down.sql new file mode 100644 index 00000000..f2c8fddc --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/sqlite_down.sql @@ -0,0 +1,4 @@ +DROP TABLE Config; +DROP TABLE "Order"; +DROP TABLE Product; +DROP TABLE "User"; diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/sqlite_up.sql b/examples/fixed-string/.butane/migrations/20251031_014308910_init/sqlite_up.sql new file mode 100644 index 00000000..be79acac --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/sqlite_up.sql @@ -0,0 +1,32 @@ +CREATE TABLE Config ( +"key" TEXT NOT NULL PRIMARY KEY, +"value" TEXT NOT NULL, +description TEXT +) STRICT; +CREATE TABLE "Order" ( +"id" INTEGER NOT NULL PRIMARY KEY, +order_number TEXT NOT NULL, +"user" INTEGER NOT NULL, +product TEXT NOT NULL, +quantity INTEGER NOT NULL, +"status" TEXT NOT NULL, +FOREIGN KEY ("user") REFERENCES "User"("id") +FOREIGN KEY (product) REFERENCES Product(sku) +) STRICT; +CREATE TABLE Product ( +sku TEXT NOT NULL PRIMARY KEY, +"name" TEXT NOT NULL, +category TEXT NOT NULL, +price_cents INTEGER NOT NULL, +in_stock INTEGER NOT NULL +) STRICT; +CREATE TABLE "User" ( +"id" INTEGER NOT NULL PRIMARY KEY, +username TEXT NOT NULL, +email TEXT NOT NULL, +display_name TEXT, +"status" TEXT NOT NULL +) STRICT; +CREATE TABLE IF NOT EXISTS butane_migrations ( +"name" TEXT NOT NULL PRIMARY KEY +) STRICT; diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/turso_down.sql b/examples/fixed-string/.butane/migrations/20251031_014308910_init/turso_down.sql new file mode 100644 index 00000000..f2c8fddc --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/turso_down.sql @@ -0,0 +1,4 @@ +DROP TABLE Config; +DROP TABLE "Order"; +DROP TABLE Product; +DROP TABLE "User"; diff --git a/examples/fixed-string/.butane/migrations/20251031_014308910_init/turso_up.sql b/examples/fixed-string/.butane/migrations/20251031_014308910_init/turso_up.sql new file mode 100644 index 00000000..d1e8de7b --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_014308910_init/turso_up.sql @@ -0,0 +1,26 @@ +CREATE TABLE Config ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT NOT NULL, description TEXT); +CREATE TABLE "Order" ( + "id" INTEGER NOT NULL PRIMARY KEY, + order_number TEXT NOT NULL, + "user" INTEGER NOT NULL, + product TEXT NOT NULL, + quantity INTEGER NOT NULL, + "status" TEXT NOT NULL, + FOREIGN KEY ("user") REFERENCES "User"("id"), + FOREIGN KEY (product) REFERENCES Product(sku) +); +CREATE TABLE Product ( + sku TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + category TEXT NOT NULL, + price_cents INTEGER NOT NULL, + in_stock INTEGER NOT NULL +); +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY, + username TEXT NOT NULL, + email TEXT NOT NULL, + display_name TEXT, + "status" TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS butane_migrations ("name" TEXT NOT NULL PRIMARY KEY); diff --git a/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/Session.table b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/Session.table new file mode 100644 index 00000000..ba3e08ae --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/Session.table @@ -0,0 +1,83 @@ +{ + "name": "Session", + "columns": [ + { + "name": "session_id", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "user_id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "ip_address", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "user_agent", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "device_fingerprint", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] +} diff --git a/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/info.json b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/info.json new file mode 100644 index 00000000..b690f063 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/info.json @@ -0,0 +1,14 @@ +{ + "from_name": "20251031_014308910_init", + "table_bases": { + "Config": "20251031_014308910_init", + "Order": "20251031_014308910_init", + "Product": "20251031_014308910_init", + "User": "20251031_014308910_init" + }, + "backends": [ + "sqlite", + "pg", + "turso" + ] +} diff --git a/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/pg_down.sql b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/pg_down.sql new file mode 100644 index 00000000..408bfb46 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/pg_down.sql @@ -0,0 +1 @@ +DROP TABLE "Session"; diff --git a/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/pg_up.sql b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/pg_up.sql new file mode 100644 index 00000000..50afeb96 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/pg_up.sql @@ -0,0 +1,8 @@ +CREATE TABLE "Session" ( +session_id TEXT NOT NULL PRIMARY KEY, +user_id BIGINT NOT NULL, +ip_address TEXT NOT NULL, +user_agent TEXT NOT NULL, +"status" TEXT NOT NULL, +device_fingerprint TEXT +); diff --git a/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/sqlite_down.sql b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/sqlite_down.sql new file mode 100644 index 00000000..408bfb46 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/sqlite_down.sql @@ -0,0 +1 @@ +DROP TABLE "Session"; diff --git a/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/sqlite_up.sql b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/sqlite_up.sql new file mode 100644 index 00000000..e0265432 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/sqlite_up.sql @@ -0,0 +1,8 @@ +CREATE TABLE "Session" ( +session_id TEXT NOT NULL PRIMARY KEY, +user_id INTEGER NOT NULL, +ip_address TEXT NOT NULL, +user_agent TEXT NOT NULL, +"status" TEXT NOT NULL, +device_fingerprint TEXT +) STRICT; diff --git a/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/turso_down.sql b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/turso_down.sql new file mode 100644 index 00000000..408bfb46 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/turso_down.sql @@ -0,0 +1 @@ +DROP TABLE "Session"; diff --git a/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/turso_up.sql b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/turso_up.sql new file mode 100644 index 00000000..084bdcee --- /dev/null +++ b/examples/fixed-string/.butane/migrations/20251031_015240522_add_sessions/turso_up.sql @@ -0,0 +1,8 @@ +CREATE TABLE "Session" ( + session_id TEXT NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL, + ip_address TEXT NOT NULL, + user_agent TEXT NOT NULL, + "status" TEXT NOT NULL, + device_fingerprint TEXT +); diff --git a/examples/fixed-string/.butane/migrations/current/Config.table b/examples/fixed-string/.butane/migrations/current/Config.table new file mode 100644 index 00000000..1d1a5381 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/current/Config.table @@ -0,0 +1,44 @@ +{ + "name": "Config", + "columns": [ + { + "name": "key", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "value", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "description", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] +} diff --git a/examples/fixed-string/.butane/migrations/current/Order.table b/examples/fixed-string/.butane/migrations/current/Order.table new file mode 100644 index 00000000..172b1264 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/current/Order.table @@ -0,0 +1,89 @@ +{ + "name": "Order", + "columns": [ + { + "name": "id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": true, + "auto": true, + "unique": false, + "default": null + }, + { + "name": "order_number", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "user", + "sqltype": { + "Deferred": "PK:User" + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null, + "reference": { + "Deferred": { + "Deferred": "PK:User" + } + } + }, + { + "name": "product", + "sqltype": { + "Deferred": "PK:Product" + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null, + "reference": { + "Deferred": { + "Deferred": "PK:Product" + } + } + }, + { + "name": "quantity", + "sqltype": { + "KnownId": { + "Ty": "Int" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] +} diff --git a/examples/fixed-string/.butane/migrations/current/Product.table b/examples/fixed-string/.butane/migrations/current/Product.table new file mode 100644 index 00000000..6b9dbf4f --- /dev/null +++ b/examples/fixed-string/.butane/migrations/current/Product.table @@ -0,0 +1,70 @@ +{ + "name": "Product", + "columns": [ + { + "name": "sku", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "name", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "category", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "price_cents", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "in_stock", + "sqltype": { + "KnownId": { + "Ty": "Bool" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] +} diff --git a/examples/fixed-string/.butane/migrations/current/Session.table b/examples/fixed-string/.butane/migrations/current/Session.table new file mode 100644 index 00000000..ba3e08ae --- /dev/null +++ b/examples/fixed-string/.butane/migrations/current/Session.table @@ -0,0 +1,83 @@ +{ + "name": "Session", + "columns": [ + { + "name": "session_id", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "user_id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "ip_address", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "user_agent", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "device_fingerprint", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] +} diff --git a/examples/fixed-string/.butane/migrations/current/User.table b/examples/fixed-string/.butane/migrations/current/User.table new file mode 100644 index 00000000..23756973 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/current/User.table @@ -0,0 +1,70 @@ +{ + "name": "User", + "columns": [ + { + "name": "id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": true, + "auto": true, + "unique": false, + "default": null + }, + { + "name": "username", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "email", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "display_name", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] +} diff --git a/examples/fixed-string/.butane/migrations/state.json b/examples/fixed-string/.butane/migrations/state.json new file mode 100644 index 00000000..84d60754 --- /dev/null +++ b/examples/fixed-string/.butane/migrations/state.json @@ -0,0 +1,3 @@ +{ + "latest": "20251031_015240522_add_sessions" +} diff --git a/examples/fixed-string/Cargo.toml b/examples/fixed-string/Cargo.toml new file mode 100644 index 00000000..4034d815 --- /dev/null +++ b/examples/fixed-string/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "fixed-string" +version = "0.1.0" +edition.workspace = true +license = "MIT OR Apache-2.0" +publish = false + +[lib] +doc = false + +[features] +default = ["pg", "sqlite", "sqlite-bundled", "turso"] +pg = ["butane/pg"] +sqlite = ["butane/sqlite"] +sqlite-bundled = ["butane/sqlite-bundled"] +turso = ["butane/turso"] + +[dependencies] +arrayvec = "0.7" +butane = {workspace = true, features = ["log"] } + +[dev-dependencies] +butane_cli.workspace = true +butane_core = { workspace = true, features = ["log"] } +butane_test_helper = { workspace = true, default-features = false, features = ["pg", "sqlite", "turso"] } +butane_test_macros.workspace = true +cfg-if.workspace = true +env_logger.workspace = true +log.workspace = true +paste.workspace = true +test-log.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/examples/fixed-string/README.md b/examples/fixed-string/README.md new file mode 100644 index 00000000..7f7a4053 --- /dev/null +++ b/examples/fixed-string/README.md @@ -0,0 +1,75 @@ +# Fixed-String Example + +This example demonstrates how to use [`ArrayString`](https://docs.rs/arrayvec/latest/arrayvec/struct.ArrayString.html) +from the `arrayvec` crate with Butane for memory-efficient, fixed-size string fields. + +## Features Demonstrated + +- **Memory Efficiency**: `ArrayString` stores string data on the stack instead of heap allocation +- **Type Safety**: Compile-time capacity checking prevents buffer overflows +- **Performance**: No heap allocations for strings within the capacity limit +- **Database Compatibility**: Works seamlessly with all Butane backends (PostgreSQL, SQLite, Turso) + +## Models + +### User + +- `username: ArrayString<32>` - Fixed-size username (32 characters max) +- `email: ArrayString<255>` - Email address (255 characters max, RFC standard) +- `display_name: Option>` - Optional display name (64 characters max) +- `status: ArrayString<16>` - User status (16 characters max) + +### Product + +- `sku: ArrayString<32>` - Product SKU as primary key (32 characters max) +- `name: ArrayString<128>` - Product name (128 characters max) +- `category: ArrayString<64>` - Product category (64 characters max) + +### Order + +- `order_number: ArrayString<32>` - Customer-facing order identifier +- Foreign key relationships to User and Product +- `status: ArrayString<16>` - Order status tracking + +### Config + +- `key: ArrayString<64>` - Configuration key as primary key +- `value: ArrayString<512>` - Configuration value +- `description: Option>` - Optional description + +## Benefits of ArrayString + +1. **Stack Allocation**: No heap overhead for small to medium strings +2. **Cache Friendly**: Better memory locality compared to `String` +3. **Predictable Memory Usage**: Fixed size at compile time +4. **Zero-Copy Operations**: No allocations for operations within capacity +5. **Database Optimization**: Stored as regular TEXT columns in the database + +## Usage + +The example shows how to: + +- Define models with `ArrayString` fields +- Handle capacity errors gracefully +- Use `ArrayString` as primary keys +- Work with optional `ArrayString` fields +- Integrate with Butane's ORM features + +## Running the Example + +```bash +# Generate migrations +cargo run --bin butane_cli -- migrate +# Run tests +cargo test +``` + +## Performance Considerations + +Choose appropriate capacities for your use case: + +- **Small strings** (≤64 chars): Significant performance benefits +- **Medium strings** (≤256 chars): Moderate benefits, reduced allocations +- **Large strings** (>512 chars): Consider using regular `String` for flexibility + +The optimal capacity depends on your specific data patterns and performance requirements. diff --git a/examples/fixed-string/src/butane_migrations.rs b/examples/fixed-string/src/butane_migrations.rs new file mode 100644 index 00000000..34817470 --- /dev/null +++ b/examples/fixed-string/src/butane_migrations.rs @@ -0,0 +1,702 @@ +//! Butane migrations embedded in Rust. + +use butane::migrations::MemMigrations; + +/// Load the butane migrations embedded in Rust. +pub fn get_migrations() -> Result { + let json = r#"{ + "migrations": { + "20251031_014308910_init": { + "name": "20251031_014308910_init", + "db": { + "tables": { + "Config": { + "name": "Config", + "columns": [ + { + "name": "key", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "value", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "description", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] + }, + "Order": { + "name": "Order", + "columns": [ + { + "name": "id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": true, + "auto": true, + "unique": false, + "default": null + }, + { + "name": "order_number", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "user", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null, + "reference": { + "Literal": { + "table_name": "User", + "column_name": "id" + } + } + }, + { + "name": "product", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null, + "reference": { + "Literal": { + "table_name": "Product", + "column_name": "sku" + } + } + }, + { + "name": "quantity", + "sqltype": { + "KnownId": { + "Ty": "Int" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] + }, + "Product": { + "name": "Product", + "columns": [ + { + "name": "sku", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "name", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "category", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "price_cents", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "in_stock", + "sqltype": { + "KnownId": { + "Ty": "Bool" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] + }, + "User": { + "name": "User", + "columns": [ + { + "name": "id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": true, + "auto": true, + "unique": false, + "default": null + }, + { + "name": "username", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "email", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "display_name", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] + } + }, + "extra_types": {} + }, + "from": null, + "up": { + "pg": "CREATE TABLE Config (\n\"key\" TEXT NOT NULL PRIMARY KEY,\n\"value\" TEXT NOT NULL,\ndescription TEXT\n);\nCREATE TABLE \"Order\" (\n\"id\" BIGSERIAL NOT NULL PRIMARY KEY,\norder_number TEXT NOT NULL,\n\"user\" BIGINT NOT NULL,\nproduct TEXT NOT NULL,\nquantity INTEGER NOT NULL,\n\"status\" TEXT NOT NULL\n);\nCREATE TABLE Product (\nsku TEXT NOT NULL PRIMARY KEY,\n\"name\" TEXT NOT NULL,\ncategory TEXT NOT NULL,\nprice_cents BIGINT NOT NULL,\nin_stock BOOLEAN NOT NULL\n);\nCREATE TABLE \"User\" (\n\"id\" BIGSERIAL NOT NULL PRIMARY KEY,\nusername TEXT NOT NULL,\nemail TEXT NOT NULL,\ndisplay_name TEXT,\n\"status\" TEXT NOT NULL\n);\nALTER TABLE \"Order\" ADD FOREIGN KEY (\"user\") REFERENCES \"User\"(\"id\");\nALTER TABLE \"Order\" ADD FOREIGN KEY (product) REFERENCES Product(sku);\nCREATE TABLE IF NOT EXISTS butane_migrations (\n\"name\" TEXT NOT NULL PRIMARY KEY\n);\n", + "sqlite": "CREATE TABLE Config (\n\"key\" TEXT NOT NULL PRIMARY KEY,\n\"value\" TEXT NOT NULL,\ndescription TEXT\n) STRICT;\nCREATE TABLE \"Order\" (\n\"id\" INTEGER NOT NULL PRIMARY KEY,\norder_number TEXT NOT NULL,\n\"user\" INTEGER NOT NULL,\nproduct TEXT NOT NULL,\nquantity INTEGER NOT NULL,\n\"status\" TEXT NOT NULL,\nFOREIGN KEY (\"user\") REFERENCES \"User\"(\"id\")\nFOREIGN KEY (product) REFERENCES Product(sku)\n) STRICT;\nCREATE TABLE Product (\nsku TEXT NOT NULL PRIMARY KEY,\n\"name\" TEXT NOT NULL,\ncategory TEXT NOT NULL,\nprice_cents INTEGER NOT NULL,\nin_stock INTEGER NOT NULL\n) STRICT;\nCREATE TABLE \"User\" (\n\"id\" INTEGER NOT NULL PRIMARY KEY,\nusername TEXT NOT NULL,\nemail TEXT NOT NULL,\ndisplay_name TEXT,\n\"status\" TEXT NOT NULL\n) STRICT;\nCREATE TABLE IF NOT EXISTS butane_migrations (\n\"name\" TEXT NOT NULL PRIMARY KEY\n) STRICT;\n", + "turso": "CREATE TABLE Config (\"key\" TEXT NOT NULL PRIMARY KEY, \"value\" TEXT NOT NULL, description TEXT);\nCREATE TABLE \"Order\" (\n \"id\" INTEGER NOT NULL PRIMARY KEY,\n order_number TEXT NOT NULL,\n \"user\" INTEGER NOT NULL,\n product TEXT NOT NULL,\n quantity INTEGER NOT NULL,\n \"status\" TEXT NOT NULL,\n FOREIGN KEY (\"user\") REFERENCES \"User\"(\"id\"),\n FOREIGN KEY (product) REFERENCES Product(sku)\n);\nCREATE TABLE Product (\n sku TEXT NOT NULL PRIMARY KEY,\n \"name\" TEXT NOT NULL,\n category TEXT NOT NULL,\n price_cents INTEGER NOT NULL,\n in_stock INTEGER NOT NULL\n);\nCREATE TABLE \"User\" (\n \"id\" INTEGER NOT NULL PRIMARY KEY,\n username TEXT NOT NULL,\n email TEXT NOT NULL,\n display_name TEXT,\n \"status\" TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS butane_migrations (\"name\" TEXT NOT NULL PRIMARY KEY);\n" + }, + "down": { + "pg": "ALTER TABLE \"Order\" DROP CONSTRAINT \"Order_user_fkey\";\nALTER TABLE \"Order\" DROP CONSTRAINT \"Order_product_fkey\";\nDROP TABLE Config;\nDROP TABLE \"Order\";\nDROP TABLE Product;\nDROP TABLE \"User\";\n", + "sqlite": "DROP TABLE Config;\nDROP TABLE \"Order\";\nDROP TABLE Product;\nDROP TABLE \"User\";\n", + "turso": "DROP TABLE Config;\nDROP TABLE \"Order\";\nDROP TABLE Product;\nDROP TABLE \"User\";\n" + } + }, + "20251031_015240522_add_sessions": { + "name": "20251031_015240522_add_sessions", + "db": { + "tables": { + "Config": { + "name": "Config", + "columns": [ + { + "name": "key", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "value", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "description", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] + }, + "Order": { + "name": "Order", + "columns": [ + { + "name": "id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": true, + "auto": true, + "unique": false, + "default": null + }, + { + "name": "order_number", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "user", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null, + "reference": { + "Literal": { + "table_name": "User", + "column_name": "id" + } + } + }, + { + "name": "product", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null, + "reference": { + "Literal": { + "table_name": "Product", + "column_name": "sku" + } + } + }, + { + "name": "quantity", + "sqltype": { + "KnownId": { + "Ty": "Int" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] + }, + "Product": { + "name": "Product", + "columns": [ + { + "name": "sku", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "name", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "category", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "price_cents", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "in_stock", + "sqltype": { + "KnownId": { + "Ty": "Bool" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] + }, + "Session": { + "name": "Session", + "columns": [ + { + "name": "session_id", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": true, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "user_id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "ip_address", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "user_agent", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "device_fingerprint", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] + }, + "User": { + "name": "User", + "columns": [ + { + "name": "id", + "sqltype": { + "KnownId": { + "Ty": "BigInt" + } + }, + "nullable": false, + "pk": true, + "auto": true, + "unique": false, + "default": null + }, + { + "name": "username", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "email", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "display_name", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": true, + "pk": false, + "auto": false, + "unique": false, + "default": null + }, + { + "name": "status", + "sqltype": { + "KnownId": { + "Ty": "Text" + } + }, + "nullable": false, + "pk": false, + "auto": false, + "unique": false, + "default": null + } + ] + } + }, + "extra_types": {} + }, + "from": "20251031_014308910_init", + "up": { + "pg": "CREATE TABLE \"Session\" (\nsession_id TEXT NOT NULL PRIMARY KEY,\nuser_id BIGINT NOT NULL,\nip_address TEXT NOT NULL,\nuser_agent TEXT NOT NULL,\n\"status\" TEXT NOT NULL,\ndevice_fingerprint TEXT\n);\n", + "sqlite": "CREATE TABLE \"Session\" (\nsession_id TEXT NOT NULL PRIMARY KEY,\nuser_id INTEGER NOT NULL,\nip_address TEXT NOT NULL,\nuser_agent TEXT NOT NULL,\n\"status\" TEXT NOT NULL,\ndevice_fingerprint TEXT\n) STRICT;\n", + "turso": "CREATE TABLE \"Session\" (\n session_id TEXT NOT NULL PRIMARY KEY,\n user_id INTEGER NOT NULL,\n ip_address TEXT NOT NULL,\n user_agent TEXT NOT NULL,\n \"status\" TEXT NOT NULL,\n device_fingerprint TEXT\n);\n" + }, + "down": { + "pg": "DROP TABLE \"Session\";\n", + "sqlite": "DROP TABLE \"Session\";\n", + "turso": "DROP TABLE \"Session\";\n" + } + } + }, + "current": { + "name": "current", + "db": { + "tables": {}, + "extra_types": {} + }, + "from": null, + "up": {}, + "down": {} + }, + "latest": "20251031_015240522_add_sessions" +}"#; + MemMigrations::from_json(json) +} diff --git a/examples/fixed-string/src/lib.rs b/examples/fixed-string/src/lib.rs new file mode 100644 index 00000000..19b5abb0 --- /dev/null +++ b/examples/fixed-string/src/lib.rs @@ -0,0 +1,8 @@ +//! Common helpers for the fixed-string example. + +#![deny(missing_docs)] + +pub mod butane_migrations; +pub mod models; + +pub use models::{Config, Order, Product, Session, User}; diff --git a/examples/fixed-string/src/models.rs b/examples/fixed-string/src/models.rs new file mode 100644 index 00000000..d79257ca --- /dev/null +++ b/examples/fixed-string/src/models.rs @@ -0,0 +1,234 @@ +//! Models for the fixed-string example demonstrating ArrayString usage. + +use arrayvec::ArrayString; +use butane::{model, AutoPk, ForeignKey}; + +/// User account with fixed-size string fields for performance. +#[model] +#[derive(Debug, Default, Clone)] +pub struct User { + /// User ID. + pub id: AutoPk, + /// Username - limited to 32 characters for efficient storage. + pub username: ArrayString<32>, + /// Email address - limited to 255 characters (RFC standard). + pub email: ArrayString<255>, + /// Optional display name - limited to 64 characters. + pub display_name: Option>, + /// User status: active, suspended, deleted, etc. + pub status: ArrayString<16>, +} + +impl User { + /// Create a new user with the given username and email. + pub fn new(username: &str, email: &str) -> Result { + let mut user = User { + id: AutoPk::uninitialized(), + username: ArrayString::new(), + email: ArrayString::new(), + display_name: None, + status: ArrayString::from("active").map_err(|_| arrayvec::CapacityError::new(()))?, + }; + + user.username.push_str(username); + if user.username.len() != username.len() { + return Err(arrayvec::CapacityError::new(())); + } + + user.email.push_str(email); + if user.email.len() != email.len() { + return Err(arrayvec::CapacityError::new(())); + } + + Ok(user) + } + + /// Set the display name for this user. + pub fn with_display_name( + mut self, + display_name: &str, + ) -> Result { + let name = ArrayString::from(display_name).map_err(|_| arrayvec::CapacityError::new(()))?; + self.display_name = Some(name); + Ok(self) + } + + /// Set the user status. + pub fn with_status(mut self, status: &str) -> Result { + self.status = ArrayString::from(status).map_err(|_| arrayvec::CapacityError::new(()))?; + Ok(self) + } +} + +/// Product catalog entry with fixed-size strings for inventory management. +#[model] +#[derive(Debug, Default, Clone)] +pub struct Product { + /// Product SKU - used as primary key, limited to 32 characters. + #[pk] + pub sku: ArrayString<32>, + /// Product name - limited to 128 characters. + pub name: ArrayString<128>, + /// Product category - limited to 64 characters. + pub category: ArrayString<64>, + /// Price in cents (to avoid floating point issues). + pub price_cents: i64, + /// Whether the product is currently in stock. + pub in_stock: bool, +} + +impl Product { + /// Create a new product. + pub fn new( + sku: &str, + name: &str, + category: &str, + price_cents: i64, + ) -> Result { + Ok(Product { + sku: ArrayString::from(sku).map_err(|_| arrayvec::CapacityError::new(()))?, + name: ArrayString::from(name).map_err(|_| arrayvec::CapacityError::new(()))?, + category: ArrayString::from(category).map_err(|_| arrayvec::CapacityError::new(()))?, + price_cents, + in_stock: true, + }) + } + + /// Set whether the product is in stock. + pub fn set_in_stock(mut self, in_stock: bool) -> Self { + self.in_stock = in_stock; + self + } +} + +/// Order tracking with fixed-size identifiers. +#[model] +#[derive(Debug, Clone)] +pub struct Order { + /// Order ID. + pub id: AutoPk, + /// Order number - customer-facing identifier, limited to 32 characters. + pub order_number: ArrayString<32>, + /// User who placed the order. + pub user: ForeignKey, + /// Product being ordered. + pub product: ForeignKey, + /// Quantity ordered. + pub quantity: i32, + /// Order status: pending, shipped, delivered, cancelled. + pub status: ArrayString<16>, +} + +impl Order { + /// Create a new order. + pub fn new( + order_number: &str, + user: User, + product: Product, + quantity: i32, + ) -> Result { + Ok(Order { + id: AutoPk::uninitialized(), + order_number: ArrayString::from(order_number) + .map_err(|_| arrayvec::CapacityError::new(()))?, + user: user.into(), + product: product.into(), + quantity, + status: ArrayString::from("pending").map_err(|_| arrayvec::CapacityError::new(()))?, + }) + } + + /// Update the order status. + pub fn with_status(mut self, status: &str) -> Result { + self.status = ArrayString::from(status).map_err(|_| arrayvec::CapacityError::new(()))?; + Ok(self) + } +} + +/// Configuration settings with fixed-size keys and values. +#[model] +#[derive(Debug, Default, Clone)] +pub struct Config { + /// Configuration key - used as primary key. + #[pk] + pub key: ArrayString<64>, + /// Configuration value. + pub value: ArrayString<512>, + /// Description of what this configuration does. + pub description: Option>, +} + +impl Config { + /// Create a new configuration entry. + pub fn new(key: &str, value: &str) -> Result { + Ok(Config { + key: ArrayString::from(key).map_err(|_| arrayvec::CapacityError::new(()))?, + value: ArrayString::from(value).map_err(|_| arrayvec::CapacityError::new(()))?, + description: None, + }) + } + + /// Add a description to the configuration. + pub fn with_description(mut self, description: &str) -> Result { + self.description = + Some(ArrayString::from(description).map_err(|_| arrayvec::CapacityError::new(()))?); + Ok(self) + } +} + +/// User session information with fixed-size string fields. +#[model] +#[derive(Debug, Default, Clone)] +pub struct Session { + /// Session ID - used as primary key. + #[pk] + pub session_id: ArrayString<128>, + /// User ID this session belongs to. + pub user_id: i64, + /// IP address from which the session was created. + pub ip_address: ArrayString<45>, // IPv6 max length + /// User agent string (truncated if necessary). + pub user_agent: ArrayString<512>, + /// Session status: active, expired, revoked. + pub status: ArrayString<16>, + /// Optional device fingerprint. + pub device_fingerprint: Option>, +} + +impl Session { + /// Create a new session. + pub fn new( + session_id: &str, + user_id: i64, + ip_address: &str, + user_agent: &str, + ) -> Result { + Ok(Session { + session_id: ArrayString::from(session_id) + .map_err(|_| arrayvec::CapacityError::new(()))?, + user_id, + ip_address: ArrayString::from(ip_address) + .map_err(|_| arrayvec::CapacityError::new(()))?, + user_agent: ArrayString::from(user_agent) + .map_err(|_| arrayvec::CapacityError::new(()))?, + status: ArrayString::from("active").map_err(|_| arrayvec::CapacityError::new(()))?, + device_fingerprint: None, + }) + } + + /// Set the device fingerprint for this session. + pub fn with_device_fingerprint( + mut self, + fingerprint: &str, + ) -> Result { + self.device_fingerprint = + Some(ArrayString::from(fingerprint).map_err(|_| arrayvec::CapacityError::new(()))?); + Ok(self) + } + + /// Update the session status. + pub fn with_status(mut self, status: &str) -> Result { + self.status = ArrayString::from(status).map_err(|_| arrayvec::CapacityError::new(()))?; + Ok(self) + } +} diff --git a/examples/fixed-string/tests/debug_constraints.rs b/examples/fixed-string/tests/debug_constraints.rs new file mode 100644 index 00000000..627fa3ac --- /dev/null +++ b/examples/fixed-string/tests/debug_constraints.rs @@ -0,0 +1,48 @@ +use butane::db::Connection; +use butane::migrations::Migrations; +use butane_test_helper::*; +use butane_test_macros::butane_test; + +#[butane_test(sync, nomigrate, pg)] +fn debug_constraint_names(mut connection: Connection) { + // Migrate forward to create the tables and constraints + let base_dir = std::path::PathBuf::from(".butane"); + let migrations = butane_cli::get_migrations(&base_dir).unwrap(); + migrations.migrate(&mut connection).unwrap(); + + // Test constraint name generation manually + println!("Testing constraint name generation..."); + + // Try with lowercase constraint name (PostgreSQL lowercases unquoted identifiers) + let drop_result0 = connection.execute("ALTER TABLE \"Order\" DROP CONSTRAINT order_user_fkey;"); + match drop_result0 { + Ok(_) => println!("Successfully dropped order_user_fkey (lowercase)"), + Err(e) => println!("Failed to drop order_user_fkey (lowercase): {}", e), + } + + // Try to drop the constraint with the exact name from the down migration + let drop_result1 = connection.execute("ALTER TABLE \"Order\" DROP CONSTRAINT Order_user_fkey;"); + match drop_result1 { + Ok(_) => println!("Successfully dropped Order_user_fkey"), + Err(e) => println!("Failed to drop Order_user_fkey: {}", e), + } + + // Try with quoted constraint name + let drop_result2 = + connection.execute("ALTER TABLE \"Order\" DROP CONSTRAINT \"Order_user_fkey\";"); + match drop_result2 { + Ok(_) => println!("Successfully dropped \"Order_user_fkey\""), + Err(e) => println!("Failed to drop \"Order_user_fkey\": {}", e), + } + + // Try to find the actual constraint name by attempting to recreate it and see what error we get + let recreate_result = connection + .execute("ALTER TABLE \"Order\" ADD FOREIGN KEY (\"user\") REFERENCES \"User\"(\"id\");"); + match recreate_result { + Ok(_) => println!("Successfully recreated constraint (shouldn't happen)"), + Err(e) => println!("Failed to recreate constraint (expected): {}", e), + } // Also check the down migration SQL to see what it's trying to drop + println!("\nDown migration attempts to drop:"); + println!("ALTER TABLE \"Order\" DROP CONSTRAINT Order_user_fkey;"); + println!("ALTER TABLE \"Order\" DROP CONSTRAINT Order_product_fkey;"); +} diff --git a/examples/fixed-string/tests/unmigrate.rs b/examples/fixed-string/tests/unmigrate.rs new file mode 100644 index 00000000..94e3ea8d --- /dev/null +++ b/examples/fixed-string/tests/unmigrate.rs @@ -0,0 +1,124 @@ +use butane::db::{Connection, ConnectionAsync}; +use butane::migrations::Migrations; +use butane_test_helper::*; +use butane_test_macros::butane_test; + +use fixed_string::{Config, Order, Product, Session, User}; + +#[maybe_async_cfg::maybe( + sync(), + async(), + idents( + Connection(sync = "Connection", async = "ConnectionAsync"), + DataObjectOps(sync = "DataObjectOpsSync", async = "DataObjectOpsAsync"), + ) +)] +async fn insert_data(connection: &Connection) { + use butane::DataObjectOps; + + // Test that tables exist and work with ArrayString fields by performing CRUD operations + // (the migrations should have been applied before this function is called) + + // Create a user with ArrayString fields + let mut user = User::new("alice", "alice@example.com").unwrap(); + user = user.with_display_name("Alice Smith").unwrap(); + user = user.with_status("active").unwrap(); + user.save(connection).await.unwrap(); + + // Verify user was saved correctly + let saved_user = User::get(connection, user.id).await.unwrap(); + assert_eq!(saved_user.username.as_str(), "alice"); + assert_eq!(saved_user.email.as_str(), "alice@example.com"); + assert_eq!( + saved_user.display_name.as_ref().unwrap().as_str(), + "Alice Smith" + ); + assert_eq!(saved_user.status.as_str(), "active"); + + // Create a product with ArrayString primary key + let mut product = Product::new("WIDGET-001", "Super Widget", "electronics", 2999).unwrap(); + product.save(connection).await.unwrap(); + + let saved_product = Product::get(connection, product.sku.clone()).await.unwrap(); + assert_eq!(saved_product.name.as_str(), "Super Widget"); + assert_eq!(saved_product.category.as_str(), "electronics"); + + // Create an order referencing the user and product + let mut order = Order::new("ORD-001", saved_user.clone(), saved_product.clone(), 2).unwrap(); + order.save(connection).await.unwrap(); + + let saved_order = Order::get(connection, order.id).await.unwrap(); + assert_eq!(saved_order.order_number.as_str(), "ORD-001"); + assert_eq!(saved_order.quantity, 2); + assert_eq!(saved_order.status.as_str(), "pending"); + + // Create a config entry with ArrayString primary key + let mut config = Config::new("max_connections", "100").unwrap(); + config = config + .with_description("Maximum database connections") + .unwrap(); + config.save(connection).await.unwrap(); + + let saved_config = Config::get(connection, config.key.clone()).await.unwrap(); + assert_eq!(saved_config.value.as_str(), "100"); + assert_eq!( + saved_config.description.as_ref().unwrap().as_str(), + "Maximum database connections" + ); + + // Create a session with ArrayString primary key + let user_id_value = saved_user.id.expect("User ID should be set after saving"); + + let mut session = Session::new( + "sess_1234567890abcdef", + user_id_value, + "192.168.1.1", + "Mozilla/5.0 (Test Browser)", + ) + .unwrap(); + session = session.with_device_fingerprint("fp_device123").unwrap(); + session.save(connection).await.unwrap(); + + let saved_session = Session::get(connection, session.session_id.clone()) + .await + .unwrap(); + assert_eq!(saved_session.user_id, user_id_value); + assert_eq!(saved_session.ip_address.as_str(), "192.168.1.1"); + assert_eq!( + saved_session.user_agent.as_str(), + "Mozilla/5.0 (Test Browser)" + ); + assert_eq!(saved_session.status.as_str(), "active"); + assert_eq!( + saved_session.device_fingerprint.as_ref().unwrap().as_str(), + "fp_device123" + ); +} + +#[test_log::test(butane_test(async, nomigrate, pg))] +async fn migrate_and_unmigrate_async(mut connection: ConnectionAsync) { + // Migrate forward. + let base_dir = std::path::PathBuf::from(".butane"); + let migrations = butane_cli::get_migrations(&base_dir).unwrap(); + + migrations.migrate_async(&mut connection).await.unwrap(); + + insert_data_async(&connection).await; + + // Undo migrations. + migrations.unmigrate_async(&mut connection).await.unwrap(); +} + +#[butane_test(sync, nomigrate)] +fn migrate_and_unmigrate_sync(mut connection: Connection) { + // Migrate forward. + let base_dir = std::path::PathBuf::from(".butane"); + let migrations = butane_cli::get_migrations(&base_dir).unwrap(); + + migrations.migrate(&mut connection).unwrap(); + + insert_data_sync(&connection); + + // Undo migrations. + migrations.unmigrate(&mut connection).unwrap(); +}