Skip to content

Commit c5167a6

Browse files
authored
Add support for non-Option, NOT NULL types (#37)
* add support for non-Option, NOT NULL types * report db path on migrations mismatch * add another db path report * remove itertools * cleanup * update ui test * first pass at `sql_default` attribute * add more default literals parsing * error if non-nullable field doesn't have an explicit default value * add default defaults * delimit sql strings with single quote instead of double
1 parent 87224d4 commit c5167a6

File tree

8 files changed

+285
-77
lines changed

8 files changed

+285
-77
lines changed

test.migrations.toml

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@
44
# Modifying it by hand may be dangerous; see the docs.
55

66
migrations_append_only = [
7-
"CREATE TABLE person (rowid INTEGER PRIMARY KEY) STRICT",
8-
"ALTER TABLE person ADD COLUMN name TEXT",
9-
"ALTER TABLE person ADD COLUMN age INTEGER",
10-
"ALTER TABLE person ADD COLUMN image_jpg BLOB",
117
"CREATE TABLE personintegrationtest (rowid INTEGER PRIMARY KEY) STRICT",
128
"ALTER TABLE personintegrationtest ADD COLUMN field_string TEXT",
139
"ALTER TABLE personintegrationtest ADD COLUMN field_i64 INTEGER",
@@ -24,12 +20,37 @@ migrations_append_only = [
2420
"ALTER TABLE personintegrationtest ADD COLUMN field_vec_u8 BLOB",
2521
"ALTER TABLE personintegrationtest ADD COLUMN field_array_u8 BLOB",
2622
"ALTER TABLE personintegrationtest ADD COLUMN field_serialize TEXT",
23+
"ALTER TABLE personintegrationtest ADD COLUMN field_string_not_null TEXT NOT NULL DEFAULT 'foo foo'",
24+
"ALTER TABLE personintegrationtest ADD COLUMN field_i64_not_null INTEGER NOT NULL DEFAULT 6",
25+
"ALTER TABLE personintegrationtest ADD COLUMN field_bool_not_null INTEGER NOT NULL DEFAULT false",
26+
"ALTER TABLE personintegrationtest ADD COLUMN field_f64_not_null REAL NOT NULL DEFAULT 1.1",
27+
"ALTER TABLE personintegrationtest ADD COLUMN field_f32_not_null REAL NOT NULL DEFAULT '2.3'",
28+
"ALTER TABLE personintegrationtest ADD COLUMN field_u8_not_null INTEGER NOT NULL DEFAULT -1.2",
29+
"ALTER TABLE personintegrationtest ADD COLUMN field_i8_not_null INTEGER NOT NULL DEFAULT 0",
30+
"ALTER TABLE personintegrationtest ADD COLUMN field_u16_not_null INTEGER NOT NULL DEFAULT 'hello'",
31+
"ALTER TABLE personintegrationtest ADD COLUMN field_i16_not_null INTEGER NOT NULL DEFAULT -1",
32+
"ALTER TABLE personintegrationtest ADD COLUMN field_u32_not_null INTEGER NOT NULL DEFAULT -1",
33+
"ALTER TABLE personintegrationtest ADD COLUMN field_i32_not_null INTEGER NOT NULL DEFAULT -1",
34+
"ALTER TABLE personintegrationtest ADD COLUMN field_blob_not_null BLOB NOT NULL DEFAULT x''",
35+
"ALTER TABLE personintegrationtest ADD COLUMN field_vec_u8_not_null BLOB NOT NULL DEFAULT x'30'",
36+
"ALTER TABLE personintegrationtest ADD COLUMN field_array_u8_not_null BLOB NOT NULL DEFAULT x'0001ff'",
37+
"ALTER TABLE personintegrationtest ADD COLUMN field_serialize_not_null TEXT NOT NULL DEFAULT ''",
38+
"CREATE TABLE nooption (rowid INTEGER PRIMARY KEY) STRICT",
39+
"ALTER TABLE nooption ADD COLUMN e INTEGER NOT NULL DEFAULT 0",
40+
"CREATE TABLE person (rowid INTEGER PRIMARY KEY) STRICT",
41+
"ALTER TABLE person ADD COLUMN name TEXT",
42+
"ALTER TABLE person ADD COLUMN age INTEGER",
43+
"ALTER TABLE person ADD COLUMN image_jpg BLOB",
2744
]
2845
output_generated_schema_for_your_information_do_not_edit = """
2946
CREATE TABLE _turbosql_migrations (
3047
rowid INTEGER PRIMARY KEY,
3148
migration TEXT NOT NULL
3249
) STRICT
50+
CREATE TABLE nooption (
51+
rowid INTEGER PRIMARY KEY,
52+
e INTEGER NOT NULL DEFAULT 0
53+
) STRICT
3354
CREATE TABLE person (
3455
rowid INTEGER PRIMARY KEY,
3556
name TEXT,
@@ -52,10 +73,38 @@ output_generated_schema_for_your_information_do_not_edit = """
5273
field_blob BLOB,
5374
field_vec_u8 BLOB,
5475
field_array_u8 BLOB,
55-
field_serialize TEXT
76+
field_serialize TEXT,
77+
field_string_not_null TEXT NOT NULL DEFAULT 'foo foo',
78+
field_i64_not_null INTEGER NOT NULL DEFAULT 6,
79+
field_bool_not_null INTEGER NOT NULL DEFAULT false,
80+
field_f64_not_null REAL NOT NULL DEFAULT 1.1,
81+
field_f32_not_null REAL NOT NULL DEFAULT '2.3',
82+
field_u8_not_null INTEGER NOT NULL DEFAULT -1.2,
83+
field_i8_not_null INTEGER NOT NULL DEFAULT 0,
84+
field_u16_not_null INTEGER NOT NULL DEFAULT 'hello',
85+
field_i16_not_null INTEGER NOT NULL DEFAULT -1,
86+
field_u32_not_null INTEGER NOT NULL DEFAULT -1,
87+
field_i32_not_null INTEGER NOT NULL DEFAULT -1,
88+
field_blob_not_null BLOB NOT NULL DEFAULT x'',
89+
field_vec_u8_not_null BLOB NOT NULL DEFAULT x'30',
90+
field_array_u8_not_null BLOB NOT NULL DEFAULT x'0001ff',
91+
field_serialize_not_null TEXT NOT NULL DEFAULT ''
5692
) STRICT
5793
"""
5894

95+
[output_generated_tables_do_not_edit.nooption]
96+
name = "nooption"
97+
98+
[[output_generated_tables_do_not_edit.nooption.columns]]
99+
name = "rowid"
100+
rust_type = "Option < i64 >"
101+
sql_type = "INTEGER PRIMARY KEY"
102+
103+
[[output_generated_tables_do_not_edit.nooption.columns]]
104+
name = "e"
105+
rust_type = "u8"
106+
sql_type = "INTEGER NOT NULL"
107+
59108
[output_generated_tables_do_not_edit.person]
60109
name = "person"
61110

@@ -161,3 +210,78 @@ sql_type = "BLOB"
161210
name = "field_serialize"
162211
rust_type = "Option < Vec < i64 > >"
163212
sql_type = "TEXT"
213+
214+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
215+
name = "field_string_not_null"
216+
rust_type = "String"
217+
sql_type = "TEXT NOT NULL"
218+
219+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
220+
name = "field_i64_not_null"
221+
rust_type = "i64"
222+
sql_type = "INTEGER NOT NULL"
223+
224+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
225+
name = "field_bool_not_null"
226+
rust_type = "bool"
227+
sql_type = "INTEGER NOT NULL"
228+
229+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
230+
name = "field_f64_not_null"
231+
rust_type = "f64"
232+
sql_type = "REAL NOT NULL"
233+
234+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
235+
name = "field_f32_not_null"
236+
rust_type = "f32"
237+
sql_type = "REAL NOT NULL"
238+
239+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
240+
name = "field_u8_not_null"
241+
rust_type = "u8"
242+
sql_type = "INTEGER NOT NULL"
243+
244+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
245+
name = "field_i8_not_null"
246+
rust_type = "i8"
247+
sql_type = "INTEGER NOT NULL"
248+
249+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
250+
name = "field_u16_not_null"
251+
rust_type = "u16"
252+
sql_type = "INTEGER NOT NULL"
253+
254+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
255+
name = "field_i16_not_null"
256+
rust_type = "i16"
257+
sql_type = "INTEGER NOT NULL"
258+
259+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
260+
name = "field_u32_not_null"
261+
rust_type = "u32"
262+
sql_type = "INTEGER NOT NULL"
263+
264+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
265+
name = "field_i32_not_null"
266+
rust_type = "i32"
267+
sql_type = "INTEGER NOT NULL"
268+
269+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
270+
name = "field_blob_not_null"
271+
rust_type = "Blob"
272+
sql_type = "BLOB NOT NULL"
273+
274+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
275+
name = "field_vec_u8_not_null"
276+
rust_type = "Vec < u8 >"
277+
sql_type = "BLOB NOT NULL"
278+
279+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
280+
name = "field_array_u8_not_null"
281+
rust_type = "[u8 ; 5]"
282+
sql_type = "BLOB NOT NULL"
283+
284+
[[output_generated_tables_do_not_edit.personintegrationtest.columns]]
285+
name = "field_serialize_not_null"
286+
rust_type = "Vec < i64 >"
287+
sql_type = "TEXT NOT NULL"

turbosql-impl/src/insert.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ pub(super) fn insert(table: &Table) -> proc_macro2::TokenStream {
99

1010
let columns = table.columns.iter().map(|c| {
1111
let ident = &c.ident;
12-
if c.sql_type == "TEXT" && c.rust_type != "Option < String >" {
12+
if c.sql_type.starts_with("TEXT") && c.rust_type != "Option < String >" && c.rust_type != "String" {
1313
quote_spanned!(c.span => &::turbosql::serde_json::to_string(&self.#ident)? as &dyn ::turbosql::ToSql)
1414
} else {
1515
quote_spanned!(c.span => &self.#ident as &dyn ::turbosql::ToSql)

turbosql-impl/src/lib.rs

Lines changed: 95 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ use syn::parse::{Parse, ParseStream};
1717
use syn::punctuated::Punctuated;
1818
use syn::spanned::Spanned;
1919
use syn::{
20-
parse_macro_input, parse_quote, Data, DeriveInput, Expr, Fields, FieldsNamed, Ident, LitStr, Meta,
21-
Token, Type,
20+
parse_macro_input, parse_quote, Data, DeriveInput, Expr, ExprLit, Fields, FieldsNamed, Ident, Lit,
21+
LitStr, Meta, MetaNameValue, Token, Type,
2222
};
2323

2424
#[cfg(not(feature = "test"))]
@@ -58,6 +58,7 @@ struct Column {
5858
name: String,
5959
rust_type: String,
6060
sql_type: &'static str,
61+
sql_default: Option<String>,
6162
}
6263

6364
#[derive(Clone, Serialize, Deserialize, Debug)]
@@ -67,8 +68,10 @@ struct MiniColumn {
6768
sql_type: String,
6869
}
6970

70-
static U8_ARRAY_RE: Lazy<regex::Regex> =
71+
static OPTION_U8_ARRAY_RE: Lazy<regex::Regex> =
7172
Lazy::new(|| regex::Regex::new(r"^Option < \[u8 ; \d+\] >$").unwrap());
73+
static U8_ARRAY_RE: Lazy<regex::Regex> =
74+
Lazy::new(|| regex::Regex::new(r"^\[u8 ; \d+\]$").unwrap());
7275

7376
#[derive(Debug)]
7477
struct SelectTokens {
@@ -489,7 +492,10 @@ fn do_parse_tokens(
489492
Content::SingleColumn(col) => col.column == c.name,
490493
_ => true,
491494
} {
492-
if c.sql_type == "TEXT" && c.rust_type != "Option < String >" {
495+
if c.sql_type.starts_with("TEXT")
496+
&& c.rust_type != "Option < String >"
497+
&& c.rust_type != "String"
498+
{
493499
Some(format!("{} AS {}__serialized", c.name, c.name))
494500
} else {
495501
Some(c.name.clone())
@@ -600,7 +606,13 @@ fn do_parse_tokens(
600606
.unwrap_or_else(|_| abort_call_site!("stmt_info.membersandcasters failed"));
601607
let row_casters = m.row_casters;
602608

603-
handle_row = quote! { #content { #(#row_casters),* } };
609+
handle_row = quote! {
610+
#[allow(clippy::needless_update)]
611+
#content {
612+
#(#row_casters),*,
613+
..Default::default()
614+
}
615+
};
604616
content_ty = quote! { #content };
605617
}
606618
Content::SingleColumn(col) => {
@@ -769,18 +781,37 @@ fn extract_columns(fields: &FieldsNamed) -> Vec<Column> {
769781
.named
770782
.iter()
771783
.filter_map(|f| {
772-
// Skip (skip) fields
784+
let mut sql_default = None;
773785

774786
for attr in &f.attrs {
775787
if attr.path().is_ident("turbosql") {
776788
for meta in attr.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated).unwrap() {
777-
match meta {
789+
match &meta {
778790
Meta::Path(path) if path.is_ident("skip") => {
779-
// TODO: For skipped fields, Handle derive(Default) requirement better
780-
// require Option and manifest None values
781791
return None;
782792
}
783-
_ => ()
793+
Meta::NameValue(MetaNameValue { path, value: Expr::Lit(ExprLit { lit, .. }), .. })
794+
if path.is_ident("sql_default") =>
795+
{
796+
match lit {
797+
Lit::Bool(value) => sql_default = Some(value.value().to_string()),
798+
Lit::Int(token) => sql_default = Some(token.to_string()),
799+
Lit::Float(token) => sql_default = Some(token.to_string()),
800+
Lit::Str(token) => sql_default = Some(format!("'{}'", token.value())),
801+
Lit::ByteStr(token) => {
802+
use std::fmt::Write;
803+
sql_default = Some(format!(
804+
"x'{}'",
805+
token.value().iter().fold(String::new(), |mut o, b| {
806+
let _ = write!(o, "{b:02x}");
807+
o
808+
})
809+
))
810+
}
811+
_ => (),
812+
}
813+
}
814+
_ => (),
784815
}
785816
}
786817
}
@@ -792,50 +823,70 @@ fn extract_columns(fields: &FieldsNamed) -> Vec<Column> {
792823
let ty = &f.ty;
793824
let ty_str = quote!(#ty).to_string();
794825

795-
// TODO: have specific error messages or advice for other numeric types
796-
// specifically, sqlite cannot represent u64 integers, would be coerced to float.
797-
// https://sqlite.org/fileformat.html
798-
799-
let sql_type = match (
826+
let (sql_type, default_example) = match (
800827
name.as_str(),
801-
if U8_ARRAY_RE.is_match(&ty_str) { "Option < [u8; _] >" } else { ty_str.as_str() },
828+
if OPTION_U8_ARRAY_RE.is_match(&ty_str) {
829+
"Option < [u8; _] >"
830+
} else if U8_ARRAY_RE.is_match(&ty_str) {
831+
"[u8; _]"
832+
} else {
833+
ty_str.as_str()
834+
},
802835
) {
803-
("rowid", "Option < i64 >") => "INTEGER PRIMARY KEY",
804-
(_, "Option < i8 >") => "INTEGER",
805-
(_, "Option < u8 >") => "INTEGER",
806-
(_, "Option < i16 >") => "INTEGER",
807-
(_, "Option < u16 >") => "INTEGER",
808-
(_, "Option < i32 >") => "INTEGER",
809-
(_, "Option < u32 >") => "INTEGER",
810-
(_, "Option < i64 >") => "INTEGER",
836+
("rowid", "Option < i64 >") => ("INTEGER PRIMARY KEY", "NULL"),
837+
(_, "Option < i8 >") => ("INTEGER", "0"),
838+
(_, "i8") => ("INTEGER NOT NULL", "0"),
839+
(_, "Option < u8 >") => ("INTEGER", "0"),
840+
(_, "u8") => ("INTEGER NOT NULL", "0"),
841+
(_, "Option < i16 >") => ("INTEGER", "0"),
842+
(_, "i16") => ("INTEGER NOT NULL", "0"),
843+
(_, "Option < u16 >") => ("INTEGER", "0"),
844+
(_, "u16") => ("INTEGER NOT NULL", "0"),
845+
(_, "Option < i32 >") => ("INTEGER", "0"),
846+
(_, "i32") => ("INTEGER NOT NULL", "0"),
847+
(_, "Option < u32 >") => ("INTEGER", "0"),
848+
(_, "u32") => ("INTEGER NOT NULL", "0"),
849+
(_, "Option < i64 >") => ("INTEGER", "0"),
850+
(_, "i64") => ("INTEGER NOT NULL", "0"),
811851
(_, "Option < u64 >") => abort!(ty, SQLITE_U64_ERROR),
812-
(_, "Option < f64 >") => "REAL",
813-
(_, "Option < f32 >") => "REAL",
814-
(_, "Option < bool >") => "INTEGER",
815-
(_, "Option < String >") => "TEXT",
852+
(_, "u64") => abort!(ty, SQLITE_U64_ERROR),
853+
(_, "Option < f64 >") => ("REAL", "0.0"),
854+
(_, "f64") => ("REAL NOT NULL", "0.0"),
855+
(_, "Option < f32 >") => ("REAL", "0.0"),
856+
(_, "f32") => ("REAL NOT NULL", "0.0"),
857+
(_, "Option < bool >") => ("INTEGER", "false"),
858+
(_, "bool") => ("INTEGER NOT NULL", "false"),
859+
(_, "Option < String >") => ("TEXT", "\"\""),
860+
(_, "String") => ("TEXT NOT NULL", "''"),
816861
// SELECT LENGTH(blob_column) ... will be null if blob is null
817-
(_, "Option < Blob >") => "BLOB",
818-
(_, "Option < Vec < u8 > >") => "BLOB",
819-
(_, "Option < [u8; _] >") => "BLOB",
862+
(_, "Option < Blob >") => ("BLOB", "b\"\""),
863+
(_, "Blob") => ("BLOB NOT NULL", "''"),
864+
(_, "Option < Vec < u8 > >") => ("BLOB", "b\"\""),
865+
(_, "Vec < u8 >") => ("BLOB NOT NULL", "''"),
866+
(_, "Option < [u8; _] >") => ("BLOB", "b\"\\x00\\x01\\xff\""),
867+
(_, "[u8; _]") => ("BLOB NOT NULL", "''"),
820868
_ => {
869+
// JSON-serialized
821870
if ty_str.starts_with("Option < ") {
822-
"TEXT" // JSON-serialized
871+
("TEXT", "\"\"")
823872
} else {
824-
abort!(
825-
ty,
826-
"Turbosql types must be wrapped in Option for forward/backward schema compatibility. Try: Option<{}>",
827-
ty_str
828-
)
873+
("TEXT NOT NULL", "''")
829874
}
830875
}
831876
};
832877

878+
if sql_default.is_none() && sql_type.ends_with("NOT NULL") {
879+
sql_default = Some(default_example.into());
880+
// abort!(f, "Field `{}` has no default value and is not nullable. Either add a default value with e.g. #[turbosql(sql_default = {default_example})] or make it Option<{ty_str}>.", name);
881+
}
882+
833883
Some(Column {
834884
ident: ident.clone().unwrap(),
835885
span: ty.span(),
836886
rust_type: ty_str,
837887
name,
838888
sql_type,
889+
sql_default,
839890
})
840891
})
841892
.collect::<Vec<_>>();
@@ -961,9 +1012,13 @@ fn make_migrations(table: &Table) -> Vec<String> {
9611012
let mut alters = table
9621013
.columns
9631014
.iter()
964-
.filter_map(|c| match (c.name.as_str(), c.sql_type) {
965-
("rowid", "INTEGER PRIMARY KEY") => None,
966-
_ => Some(format!("ALTER TABLE {} ADD COLUMN {} {}", table.name, c.name, c.sql_type)),
1015+
.filter_map(|c| match (c.name.as_str(), c.sql_type, &c.sql_default) {
1016+
("rowid", "INTEGER PRIMARY KEY", _) => None,
1017+
(_, _, None) => Some(format!("ALTER TABLE {} ADD COLUMN {} {}", table.name, c.name, c.sql_type)),
1018+
(_, _, Some(sql_default)) => Some(format!(
1019+
"ALTER TABLE {} ADD COLUMN {} {} DEFAULT {}",
1020+
table.name, c.name, c.sql_type, sql_default
1021+
)),
9671022
})
9681023
.collect::<Vec<_>>();
9691024

0 commit comments

Comments
 (0)