feat(sqlite): support attached database schema changes

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-20 10:51:16 +08:00
1 parent 890fd70c05
commit 85836f8e95
41 files changed
+1219 -129

No files matched your search

@@ -131,7 +131,11 @@ pub(super) fn build_drop_index_sql(
}
if matches!(
dialect,
StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Informix
StructureDialect::Postgres
| StructureDialect::Oracle
| StructureDialect::Dameng
| StructureDialect::Informix
| StructureDialect::Sqlite
) && schema.is_some_and(|schema| !schema.trim().is_empty())
{
return format!("DROP INDEX {}.{};", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, index_name));
@@ -200,15 +204,22 @@ pub(super) fn build_create_index_statements(
let supports_where = capabilities.index_filter
&& matches!(dialect, StructureDialect::Postgres | StructureDialect::SqlServer | StructureDialect::Sqlite);
let where_clause = if !filter.is_empty() && supports_where { format!(" WHERE {filter}") } else { String::new() };
let index_name = if dialect == StructureDialect::Sqlite && schema.is_some_and(|schema| !schema.trim().is_empty()) {
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, &name))
} else {
quote_ident(dialect, &name)
};
// SQLite assigns an index to the database named by the qualified index.
// Its CREATE INDEX grammar does not allow a qualified table name.
let create_table =
if dialect == StructureDialect::Sqlite { quote_ident(dialect, table_name) } else { table.to_string() };
let create_sql = if dialect == StructureDialect::Postgres {
format!(
"CREATE {unique}{type_prefix}INDEX {} ON {table}{using_clause} ({cols}){include_clause}{where_clause};",
quote_ident(dialect, &name)
"CREATE {unique}{type_prefix}INDEX {index_name} ON {create_table}{using_clause} ({cols}){include_clause}{where_clause};"
)
} else {
format!(
"CREATE {unique}{type_prefix}INDEX {}{using_clause} ON {table} ({cols}){include_clause}{where_clause}{comment_clause};",
quote_ident(dialect, &name)
"CREATE {unique}{type_prefix}INDEX {index_name}{using_clause} ON {create_table} ({cols}){include_clause}{where_clause}{comment_clause};"
)
};
let mut statements = vec![create_sql];
@@ -129,9 +129,6 @@ fn build_change_plan(conn: &mut Connection, options: &TableStructureSqlOptions)
return Err("SQLite table rebuild requires databaseType=sqlite.".to_string());
}
let schema = options.schema.as_deref().map(str::trim).filter(|schema| !schema.is_empty()).unwrap_or("main");
if !schema.eq_ignore_ascii_case("main") {
return Err("SQLite table rebuild currently supports the native main schema only.".to_string());
}
if options.table_name.trim().is_empty() {
return Err("SQLite table name cannot be empty.".to_string());
}
@@ -216,7 +213,7 @@ fn build_change_plan(conn: &mut Connection, options: &TableStructureSqlOptions)
let mut generic_options = options.clone();
generic_options.database_type = Some(DatabaseType::Sqlite);
generic_options.schema = None;
generic_options.schema = (!schema.eq_ignore_ascii_case("main")).then(|| schema.to_string());
generic_options.triggers.clear();
for column in &mut generic_options.columns {
if let Some(original) = &column.original {
@@ -395,7 +392,7 @@ fn load_schema_snapshot(conn: &Connection, schema: &str, table_name: &str) -> Re
drop(dependency_stmt);
let schema_version: i64 = conn
.pragma_query_value(Some(rusqlite::DatabaseName::Main), "schema_version", |row| row.get(0))
.query_row(&format!("PRAGMA {schema_ident}.schema_version"), [], |row| row.get(0))
.map_err(|error| format!("Failed to read SQLite schema version: {error}"))?;
let autoincrement_sequence = if contains_sql_keyword(&table_sql, "autoincrement") {
conn.query_row(
@@ -481,8 +478,16 @@ fn build_rebuild_statements(
replaced_index_names: &HashSet<String>,
) -> Result<Vec<String>, String> {
let mut create_replacement = rewrite_declared_column_types(&snapshot.table_sql, type_changes)?;
let (_, body_end) = find_create_table_body_span(&create_replacement)
let (body_start, body_end) = find_create_table_body_span(&create_replacement)
.ok_or_else(|| "SQLite CREATE TABLE statement could not be parsed safely.".to_string())?;
let without_rowid = has_without_rowid_clause(&create_replacement[body_end + 1..]);
if !snapshot.schema.eq_ignore_ascii_case("main") {
create_replacement = format!(
"CREATE TABLE {}{}",
qualified_name(&snapshot.schema, &snapshot.table_name),
&create_replacement[body_start..]
);
}
ensure_statement_terminated(&mut create_replacement);
let backup_seed = u64::from_str_radix(&snapshot.revision[..15], 16)
.map_err(|error| format!("Failed to derive SQLite backup table suffix: {error}"))?;
@@ -508,7 +513,6 @@ fn build_rebuild_statements(
let qualified_backup = qualified_name(&snapshot.schema, &backup_name);
let qualified_source = qualified_name(&snapshot.schema, &source_name);
let without_rowid = has_without_rowid_clause(&create_replacement[body_end + 1..]);
let visible_columns = snapshot.columns.iter().filter(|column| column.hidden == 0).collect::<Vec<_>>();
let live_names = snapshot.columns.iter().map(|column| column.name.to_ascii_lowercase()).collect::<HashSet<_>>();
let rowid_alias = if without_rowid {
@@ -581,19 +585,23 @@ fn build_rebuild_statements(
));
}
statements.push(format!("DROP TABLE {qualified_source};"));
statements.extend(
snapshot
.dependencies
.iter()
.filter(|dependency| {
dependency.kind != "index" || !replaced_index_names.contains(&dependency.name.to_ascii_lowercase())
})
.map(|dependency| {
let mut sql = dependency.sql.clone();
ensure_statement_terminated(&mut sql);
sql
}),
);
let dependency_statements = snapshot
.dependencies
.iter()
.filter(|dependency| {
dependency.kind != "index" || !replaced_index_names.contains(&dependency.name.to_ascii_lowercase())
})
.map(|dependency| -> Result<String, String> {
let mut sql = if snapshot.schema.eq_ignore_ascii_case("main") {
dependency.sql.clone()
} else {
qualify_sqlite_create_object_sql(&dependency.sql, &dependency.kind, &snapshot.schema, &dependency.name)?
};
ensure_statement_terminated(&mut sql);
Ok(sql)
})
.collect::<Result<Vec<_>, _>>()?;
statements.extend(dependency_statements);
Ok(statements)
}
@@ -963,7 +971,7 @@ fn parse_column_type_span(entry: &str) -> Option<(String, usize, usize)> {
fn parse_identifier(value: &str, start: usize) -> Option<(String, usize, bool)> {
let bytes = value.as_bytes();
let first = *bytes.get(start)?;
if matches!(first, b'"' | b'`' | b'[') {
if matches!(first, b'\'' | b'"' | b'`' | b'[') {
let close = if first == b'[' { b']' } else { first };
let mut decoded = String::new();
let mut index = start + 1;
@@ -1276,6 +1284,153 @@ fn sql_words_outside_comments_and_quotes(sql: &str) -> Vec<String> {
words
}
fn qualify_sqlite_create_object_sql(
sql: &str,
kind: &str,
schema: &str,
expected_name: &str,
) -> Result<String, String> {
let keyword = match kind.to_ascii_lowercase().as_str() {
"index" => "index",
"trigger" => "trigger",
_ => return Err(format!("Unsupported SQLite dependency kind \"{kind}\".")),
};
let keyword_end = find_sql_keyword_end(sql, keyword)
.ok_or_else(|| format!("SQLite {kind} definition does not contain a {keyword} keyword."))?;
let mut object_start = skip_space_and_comments(sql, keyword_end)
.ok_or_else(|| format!("SQLite {kind} definition ended before the object name."))?;
if let Some((word, word_end, quoted)) = parse_identifier(sql, object_start) {
if !quoted && word.eq_ignore_ascii_case("if") {
let not_start = skip_space_and_comments(sql, word_end)
.ok_or_else(|| format!("SQLite {kind} IF clause is incomplete."))?;
let (not_word, not_end, not_quoted) =
parse_identifier(sql, not_start).ok_or_else(|| format!("SQLite {kind} IF clause is incomplete."))?;
let exists_start = skip_space_and_comments(sql, not_end)
.ok_or_else(|| format!("SQLite {kind} IF NOT clause is incomplete."))?;
let (exists_word, exists_end, exists_quoted) = parse_identifier(sql, exists_start)
.ok_or_else(|| format!("SQLite {kind} IF NOT clause is incomplete."))?;
if not_quoted
|| exists_quoted
|| !not_word.eq_ignore_ascii_case("not")
|| !exists_word.eq_ignore_ascii_case("exists")
{
return Err(format!("SQLite {kind} definition has an invalid IF NOT EXISTS clause."));
}
object_start = skip_space_and_comments(sql, exists_end)
.ok_or_else(|| format!("SQLite {kind} definition ended before the object name."))?;
}
}
let (first_name, first_end, first_quoted) = parse_identifier(sql, object_start)
.ok_or_else(|| format!("SQLite {kind} definition has an invalid object name."))?;
let mut actual_name = first_name.clone();
let mut object_end = first_end;
let after_first = skip_space_and_comments(sql, first_end)
.ok_or_else(|| format!("SQLite {kind} definition has an invalid object name."))?;
if sql.as_bytes().get(after_first) == Some(&b'.') {
let second_start = skip_space_and_comments(sql, after_first + 1)
.ok_or_else(|| format!("SQLite {kind} definition has an incomplete qualified name."))?;
let (second_name, second_end, _) = parse_identifier(sql, second_start)
.ok_or_else(|| format!("SQLite {kind} definition has an incomplete qualified name."))?;
actual_name = second_name;
object_end = second_end;
} else if !first_quoted {
actual_name = first_name.rsplit_once('.').map_or(first_name.as_str(), |(_, name)| name).to_string();
}
if !actual_name.eq_ignore_ascii_case(expected_name) {
return Err(format!(
"SQLite {kind} definition name \"{actual_name}\" does not match metadata name \"{expected_name}\"."
));
}
let mut qualified = sql.to_string();
qualified.replace_range(object_start..object_end, &qualified_name(schema, expected_name));
Ok(qualified)
}
fn find_sql_keyword_end(sql: &str, keyword: &str) -> Option<usize> {
let bytes = sql.as_bytes();
let mut mode = ScanMode::Normal;
let mut index = 0_usize;
while index < bytes.len() {
let byte = bytes[index];
match mode {
ScanMode::Normal => match byte {
b'\'' => mode = ScanMode::SingleQuote,
b'"' => mode = ScanMode::DoubleQuote,
b'`' => mode = ScanMode::Backtick,
b'[' => mode = ScanMode::Bracket,
b'-' if bytes.get(index + 1) == Some(&b'-') => {
mode = ScanMode::LineComment;
index += 1;
}
b'/' if bytes.get(index + 1) == Some(&b'*') => {
mode = ScanMode::BlockComment;
index += 1;
}
_ if byte.is_ascii_alphabetic() || byte == b'_' => {
let start = index;
index += 1;
while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') {
index += 1;
}
if sql[start..index].eq_ignore_ascii_case(keyword) {
return Some(index);
}
continue;
}
_ => {}
},
ScanMode::SingleQuote => {
if byte == b'\'' {
if bytes.get(index + 1) == Some(&b'\'') {
index += 1;
} else {
mode = ScanMode::Normal;
}
}
}
ScanMode::DoubleQuote => {
if byte == b'"' {
if bytes.get(index + 1) == Some(&b'"') {
index += 1;
} else {
mode = ScanMode::Normal;
}
}
}
ScanMode::Backtick => {
if byte == b'`' {
if bytes.get(index + 1) == Some(&b'`') {
index += 1;
} else {
mode = ScanMode::Normal;
}
}
}
ScanMode::Bracket => {
if byte == b']' {
mode = ScanMode::Normal;
}
}
ScanMode::LineComment => {
if byte == b'\n' {
mode = ScanMode::Normal;
}
}
ScanMode::BlockComment => {
if byte == b'*' && bytes.get(index + 1) == Some(&b'/') {
mode = ScanMode::Normal;
index += 1;
}
}
}
index += 1;
}
None
}
fn quote_ident(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
@@ -1479,6 +1634,221 @@ mod tests {
assert_eq!(backup_data.rows[0], serde_json::json!(["text", "42"]).as_array().unwrap().clone());
}
#[tokio::test]
async fn rebuilds_attached_schema_without_touching_same_named_main_objects() {
let pool = db::sqlite::connect_path(":memory:").await.unwrap();
pool.with_connection(|conn| {
conn.execute_batch(
"PRAGMA foreign_keys=ON;
ATTACH DATABASE ':memory:' AS analytics;
CREATE TABLE main.audit(message TEXT);
CREATE TABLE main.items(id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL);
CREATE INDEX main.idx_items_value ON items(value);
CREATE TRIGGER main.trg_items_ai AFTER INSERT ON items
BEGIN INSERT INTO audit VALUES ('main:' || NEW.value); END;
INSERT INTO main.items(value) VALUES ('11');
DELETE FROM main.audit;
CREATE TABLE analytics.audit(message TEXT);
CREATE TABLE analytics.items(id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL);
CREATE INDEX analytics.idx_items_value ON items(value);
CREATE TRIGGER analytics.trg_items_ai AFTER INSERT ON items
BEGIN INSERT INTO audit VALUES ('analytics:' || NEW.value); END;
INSERT INTO analytics.items(id, value) VALUES (100, '42');
DELETE FROM analytics.audit;",
)
.map_err(|error| error.to_string())
})
.unwrap();
let mut options = type_change_options("items", "value", "TEXT", "INTEGER");
options.schema = Some("analytics".to_string());
options.columns.push(EditableStructureColumn {
id: "note".to_string(),
name: "note".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
default_value: String::new(),
comment: String::new(),
is_primary_key: false,
extra: None,
original: None,
original_position: None,
marked_for_drop: false,
character_set: String::new(),
collation: String::new(),
});
options.indexes.push(EditableStructureIndex {
id: "idx_items_note".to_string(),
name: "idx_items_note".to_string(),
columns: vec!["note".to_string()],
is_unique: false,
is_primary: false,
filter: String::new(),
index_type: String::new(),
included_columns: Vec::new(),
comment: String::new(),
original: None,
marked_for_drop: false,
});
let preview = preview_sqlite_table_structure_change_with_pool(pool.clone(), options.clone()).await.unwrap();
assert!(preview.warnings.is_empty(), "{:?}", preview.warnings);
assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE TABLE \"analytics\".\"items\"")));
assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE INDEX \"analytics\".\"idx_items_value\"")));
assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE TRIGGER \"analytics\".\"trg_items_ai\"")));
// A main-schema change after preview must not invalidate an attached-schema revision.
db::sqlite::execute_query(&pool, "CREATE TABLE main.unrelated(id INTEGER);").await.unwrap();
apply_sqlite_table_structure_change_with_pool(pool.clone(), options, &preview.schema_revision).await.unwrap();
let main_value =
db::sqlite::execute_query(&pool, "SELECT typeof(value), value FROM main.items;").await.unwrap();
assert_eq!(main_value.rows[0], serde_json::json!(["text", "11"]).as_array().unwrap().clone());
let attached_value =
db::sqlite::execute_query(&pool, "SELECT typeof(value), value FROM analytics.items;").await.unwrap();
assert_eq!(attached_value.rows[0], serde_json::json!(["integer", 42]).as_array().unwrap().clone());
let main_note = db::sqlite::execute_query(
&pool,
"SELECT count(*) FROM pragma_table_info('items', 'main') WHERE name='note';",
)
.await
.unwrap();
assert_eq!(main_note.rows[0][0], serde_json::json!(0));
let attached_note = db::sqlite::execute_query(
&pool,
"SELECT count(*) FROM pragma_table_info('items', 'analytics') WHERE name='note';",
)
.await
.unwrap();
assert_eq!(attached_note.rows[0][0], serde_json::json!(1));
let attached_note_index = db::sqlite::execute_query(
&pool,
"SELECT count(*) FROM analytics.sqlite_schema WHERE type='index' AND name='idx_items_note';",
)
.await
.unwrap();
assert_eq!(attached_note_index.rows[0][0], serde_json::json!(1));
let main_note_index = db::sqlite::execute_query(
&pool,
"SELECT count(*) FROM main.sqlite_schema WHERE type='index' AND name='idx_items_note';",
)
.await
.unwrap();
assert_eq!(main_note_index.rows[0][0], serde_json::json!(0));
db::sqlite::execute_query(&pool, "INSERT INTO analytics.items(value) VALUES (7);").await.unwrap();
let main_audit = db::sqlite::execute_query(&pool, "SELECT count(*) FROM main.audit;").await.unwrap();
assert_eq!(main_audit.rows[0][0], serde_json::json!(0));
let attached_audit = db::sqlite::execute_query(&pool, "SELECT message FROM analytics.audit;").await.unwrap();
assert_eq!(attached_audit.rows[0][0], serde_json::json!("analytics:7"));
let attached_max_id = db::sqlite::execute_query(&pool, "SELECT max(id) FROM analytics.items;").await.unwrap();
assert_eq!(attached_max_id.rows[0][0], serde_json::json!(101));
for schema in ["main", "analytics"] {
let dependencies = db::sqlite::execute_query(
&pool,
&format!(
"SELECT type, name FROM {schema}.sqlite_schema \
WHERE name IN ('idx_items_value', 'trg_items_ai') ORDER BY type;"
),
)
.await
.unwrap();
assert_eq!(dependencies.rows.len(), 2);
}
let main_backups = db::sqlite::execute_query(
&pool,
"SELECT count(*) FROM main.sqlite_schema WHERE type='table' AND name GLOB 'items_[0-9]*';",
)
.await
.unwrap();
assert_eq!(main_backups.rows[0][0], serde_json::json!(0));
let attached_backups = db::sqlite::execute_query(
&pool,
"SELECT count(*) FROM analytics.sqlite_schema WHERE type='table' AND name GLOB 'items_[0-9]*';",
)
.await
.unwrap();
assert_eq!(attached_backups.rows[0][0], serde_json::json!(1));
}
#[tokio::test]
async fn rebuilds_keyword_named_attached_schema_with_single_quoted_dependencies() {
let pool = db::sqlite::connect_path(":memory:").await.unwrap();
db::sqlite::execute_query(
&pool,
"ATTACH DATABASE ':memory:' AS \"select\";
CREATE TABLE \"select\".audit(message TEXT);
CREATE TABLE \"select\".items(value TEXT NOT NULL);
CREATE INDEX \"select\".'idx weird' ON items(value);
CREATE TRIGGER \"select\".'trg weird' AFTER INSERT ON items
BEGIN INSERT INTO audit VALUES ('attached:' || NEW.value); END;
INSERT INTO \"select\".items VALUES ('42');
DELETE FROM \"select\".audit;",
)
.await
.unwrap();
let definitions = db::sqlite::execute_query(
&pool,
"SELECT sql FROM \"select\".sqlite_schema
WHERE name IN ('idx weird', 'trg weird') ORDER BY type;",
)
.await
.unwrap();
assert_eq!(definitions.rows.len(), 2);
assert!(definitions.rows.iter().all(|row| row[0].as_str().unwrap().contains("'")));
let mut options = type_change_options("items", "value", "TEXT", "INTEGER");
options.schema = Some("select".to_string());
let preview = preview_sqlite_table_structure_change_with_pool(pool.clone(), options.clone()).await.unwrap();
assert!(preview.warnings.is_empty(), "{:?}", preview.warnings);
assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE TABLE \"select\".\"items\"")));
assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE INDEX \"select\".\"idx weird\"")));
assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE TRIGGER \"select\".\"trg weird\"")));
apply_sqlite_table_structure_change_with_pool(pool.clone(), options, &preview.schema_revision).await.unwrap();
let value =
db::sqlite::execute_query(&pool, "SELECT typeof(value), value FROM \"select\".items;").await.unwrap();
assert_eq!(value.rows[0], serde_json::json!(["integer", 42]).as_array().unwrap().clone());
db::sqlite::execute_query(&pool, "INSERT INTO \"select\".items VALUES (7);").await.unwrap();
let audit = db::sqlite::execute_query(&pool, "SELECT message FROM \"select\".audit;").await.unwrap();
assert_eq!(audit.rows[0][0], serde_json::json!("attached:7"));
let dependencies = db::sqlite::execute_query(
&pool,
"SELECT type, name FROM \"select\".sqlite_schema
WHERE name IN ('idx weird', 'trg weird') ORDER BY type;",
)
.await
.unwrap();
assert_eq!(dependencies.rows.len(), 2);
}
#[tokio::test]
async fn attached_schema_change_after_preview_is_rejected_before_mutation() {
let pool = db::sqlite::connect_path(":memory:").await.unwrap();
db::sqlite::execute_query(
&pool,
"ATTACH DATABASE ':memory:' AS analytics;
CREATE TABLE analytics.items(value TEXT NOT NULL);
INSERT INTO analytics.items VALUES ('42');",
)
.await
.unwrap();
let mut options = type_change_options("items", "value", "TEXT", "INTEGER");
options.schema = Some("analytics".to_string());
let preview = preview_sqlite_table_structure_change_with_pool(pool.clone(), options.clone()).await.unwrap();
db::sqlite::execute_query(&pool, "CREATE TABLE analytics.unrelated(id INTEGER);").await.unwrap();
let error = apply_sqlite_table_structure_change_with_pool(pool.clone(), options, &preview.schema_revision)
.await
.unwrap_err();
assert!(error.contains("schema changed"), "{error}");
let value =
db::sqlite::execute_query(&pool, "SELECT typeof(value), value FROM analytics.items;").await.unwrap();
assert_eq!(value.rows[0], serde_json::json!(["text", "42"]).as_array().unwrap().clone());
}
#[tokio::test]
async fn unrelated_existing_foreign_key_violation_does_not_block_rebuild() {
let pool = db::sqlite::connect_path(":memory:").await.unwrap();
@@ -1237,6 +1237,82 @@ fn warns_for_sqlite_unsafe_column_changes() {
);
}
#[test]
fn qualifies_attached_sqlite_table_and_index_changes() {
let mut email = column("email");
email.data_type = "text".to_string();
let mut old_index = index("idx_users_old", &["email"]);
old_index.marked_for_drop = true;
old_index.original = Some(IndexInfo {
name: "idx_users_old".to_string(),
columns: vec!["email".to_string()],
is_unique: false,
is_primary: false,
filter: None,
index_type: None,
included_columns: None,
comment: None,
});
let email_index = index("idx_users_email", &["email"]);
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Sqlite),
schema: Some("analytics".to_string()),
table_name: "users".to_string(),
columns: vec![email],
indexes: vec![old_index, email_index],
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"ALTER TABLE \"analytics\".\"users\" ADD COLUMN \"email\" text;",
"DROP INDEX \"analytics\".\"idx_users_old\";",
"CREATE INDEX \"analytics\".\"idx_users_email\" ON \"users\" (\"email\");",
]
);
let connection = rusqlite::Connection::open_in_memory().unwrap();
connection
.execute_batch(
"ATTACH DATABASE ':memory:' AS analytics;
CREATE TABLE main.users(id INTEGER);
CREATE TABLE analytics.users(id INTEGER);
CREATE INDEX analytics.idx_users_old ON users(id);",
)
.unwrap();
connection.execute_batch(&result.statements.join("\n")).unwrap();
let main_columns = connection
.prepare("PRAGMA main.table_info('users')")
.unwrap()
.query_map([], |row| row.get::<_, String>("name"))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
let attached_columns = connection
.prepare("PRAGMA analytics.table_info('users')")
.unwrap()
.query_map([], |row| row.get::<_, String>("name"))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
let attached_indexes = connection
.prepare("PRAGMA analytics.index_list('users')")
.unwrap()
.query_map([], |row| row.get::<_, String>("name"))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(main_columns, vec!["id"]);
assert_eq!(attached_columns, vec!["id", "email"]);
assert_eq!(attached_indexes, vec!["idx_users_email"]);
}
#[test]
fn builds_rqlite_changes_with_sqlite_dialect() {
let mut email = column("email");
@@ -10,6 +10,7 @@ pub(super) fn qualified_table(dialect: StructureDialect, schema: Option<&str>, t
| StructureDialect::SqlServer
| StructureDialect::H2
| StructureDialect::Informix
| StructureDialect::Sqlite
) && schema.is_some_and(|schema| !schema.trim().is_empty())
{
return format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, table_name));