From 841dfadde588849079e3d0871fdf7bc383564c82 Mon Sep 17 00:00:00 2001 From: Felipe Canin Novaes Date: Wed, 4 Mar 2026 18:33:32 -0300 Subject: [PATCH] update --- src/application/usecases/layouts.rs | 49 +++++++++-- src/domain/errors.rs | 2 + src/infrastructure/sqlite/AGENTS.md | 18 +++- src/infrastructure/sqlite/connection.rs | 4 +- .../sqlite/layout_repository.rs | 83 ++++++++++++++----- src/infrastructure/sqlite/migrations.rs | 78 ++++++++--------- 6 files changed, 156 insertions(+), 78 deletions(-) diff --git a/src/application/usecases/layouts.rs b/src/application/usecases/layouts.rs index dcac9c3..b7f0589 100644 --- a/src/application/usecases/layouts.rs +++ b/src/application/usecases/layouts.rs @@ -5,6 +5,17 @@ use crate::domain::{ use crate::infrastructure::sqlite::layout_repository; use rusqlite::Connection; +/// Mapeia um erro rusqlite para `ErroLayout`, distinguindo conflito de nome +/// de erros genéricos de banco. +fn mapear_erro_banco(e: rusqlite::Error, nome: &str) -> ErroLayout { + let msg = e.to_string(); + if msg.contains("UNIQUE constraint failed") { + ErroLayout::NomeConflitante(nome.to_string()) + } else { + ErroLayout::ErroBanco(msg) + } +} + /// Salva um layout no banco de dados. /// Se o layout já tem um id, atualiza. Caso contrário, insere. /// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome. @@ -14,19 +25,41 @@ pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result *i = Some(id), } layout_repository::atualizar(conn, &layout) - .map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; + .map_err(|e| mapear_erro_banco(e, &nome_atual))?; return Ok(id); } } else { @@ -107,5 +140,5 @@ pub fn importar_layout_json( } // Inserir novo - layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string())) + layout_repository::salvar(conn, &layout).map_err(|e| mapear_erro_banco(e, &nome_atual)) } diff --git a/src/domain/errors.rs b/src/domain/errors.rs index a10a98a..463eec5 100644 --- a/src/domain/errors.rs +++ b/src/domain/errors.rs @@ -24,6 +24,8 @@ pub enum ErroLayout { JsonMalformado(String), #[error("Conflito de nome: layout '{0}' já existe")] NomeConflitante(String), + #[error("Erro no banco de dados: {0}")] + ErroBanco(String), } #[derive(Debug, Error, Clone)] diff --git a/src/infrastructure/sqlite/AGENTS.md b/src/infrastructure/sqlite/AGENTS.md index d7f5163..15427b2 100644 --- a/src/infrastructure/sqlite/AGENTS.md +++ b/src/infrastructure/sqlite/AGENTS.md @@ -62,6 +62,8 @@ Delega para `abrir_banco_no_caminho()` com o caminho padrão. Retorna `config.db.bak` e segue para a criação de um banco novo. 4. Cria um banco vazio e retorna com flag `true` (banco foi recriado). +> **Nota:** `SELECT 1` não acessa páginas de dados do SQLite e não detecta corrupção real. Um banco com tabelas ou índices corrompidos passaria nessa verificação. `PRAGMA quick_check` seria mais confiável (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 8). + --- ## migrations.rs @@ -88,7 +90,11 @@ Fluxo: - `versao_atual < 1` → `migration_v1` - `versao_atual < 2` → `migration_v2` - `versao_atual < 3` → `migration_v3` -4. Persiste a nova versão (`INSERT` se era `0`, `UPDATE` caso contrário). +4. Persiste a nova versão na tabela `schema_version`: + - `versao_atual == 0` → `INSERT INTO schema_version (versao) VALUES (3)` + - `versao_atual > 0 && versao_atual < 3` → `UPDATE schema_version SET versao = 3` + +> **Atenção:** A atualização de `schema_version` ocorre **fora** das migrations individuais, após todas terem sido executadas. Isso significa que uma falha parcial entre migrations pode causar re-execução problemática na próxima inicialização (ver `docs/FIX_SALVAR_LAYOUT.md`, Problemas 1 e 2). ### Histórico de migrations @@ -149,8 +155,10 @@ Insere um novo layout e retorna o `rowid` gerado. Atualiza um layout existente pelo `id` embutido na variante. Retorna erro se `id` for `None`. -- `Layout::Csv` → atualiza apenas as colunas CSV. -- `Layout::Xlsx` → atualiza apenas as colunas XLSX. +- `Layout::Csv` → atualiza apenas as colunas CSV; colunas XLSX ficam inalteradas no banco. +- `Layout::Xlsx` → atualiza apenas as colunas XLSX; colunas CSV ficam inalteradas no banco. + +> **Atenção:** O UPDATE não zera os campos do tipo oposto. Se um layout mudar de tipo (ex: via importação JSON com mesmo nome), os campos do tipo anterior persistem no banco (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 9). #### `listar(conn) -> Result>` @@ -161,6 +169,8 @@ Seleciona todos os layouts ordenados por `nome ASC`. Para cada linha: Campos opcionais (`Option`) são lidos como `Option` e convertidos. +> **Atenção:** O mapeamento usa índices posicionais (`row.get(0)`, `row.get(1)`, etc.) e não nomes de colunas. Qualquer reordenação no `SELECT` quebra silenciosamente o resultado (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 6). + #### `excluir(conn, id) -> Result<()>` Remove o registro com o `id` informado via `DELETE`. @@ -187,7 +197,7 @@ aplicar_migrations(conn) ├─ cria schema_version se necessário ├─ lê versão atual ├─ executa migrations pendentes (v1 → v2 → v3) - └─ grava versão final + └─ grava versão final (INSERT se versao=0, UPDATE se versao<3) ``` Após esse fluxo, a conexão está pronta para uso pelo `layout_repository`. diff --git a/src/infrastructure/sqlite/connection.rs b/src/infrastructure/sqlite/connection.rs index 1cf9562..d05eb4d 100644 --- a/src/infrastructure/sqlite/connection.rs +++ b/src/infrastructure/sqlite/connection.rs @@ -28,8 +28,8 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri if caminho.exists() { match Connection::open(caminho) { Ok(conn) => { - // Testar se o banco funciona com uma query simples - match conn.execute_batch("SELECT 1;") { + // Testar integridade estrutural do banco + match conn.execute_batch("PRAGMA quick_check;") { Ok(_) => return Ok((conn, false)), Err(_e) => { // Banco corrompido diff --git a/src/infrastructure/sqlite/layout_repository.rs b/src/infrastructure/sqlite/layout_repository.rs index 00c6569..f7e5f55 100644 --- a/src/infrastructure/sqlite/layout_repository.rs +++ b/src/infrastructure/sqlite/layout_repository.rs @@ -1,6 +1,22 @@ use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx}; use rusqlite::{params, Connection, Result}; +/// Converte o char delimitador para string legível no banco. +fn delim_para_str(c: char) -> String { + match c { + '\t' => "tab".to_string(), + c => c.to_string(), + } +} + +/// Converte a string armazenada no banco de volta para char delimitador. +fn str_para_delim(s: &str) -> char { + match s { + "tab" => '\t', + s => s.chars().next().unwrap_or(';'), + } +} + /// Salva um layout no banco. Retorna o id gerado. pub fn salvar(conn: &Connection, layout: &Layout) -> Result { match layout { @@ -13,7 +29,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result { VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ nome, - config.delimitador.to_string(), + delim_para_str(config.delimitador), config.encoding, config.linha_cabecalho as i64, config.indice_numero as i64, @@ -47,6 +63,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result { } /// Atualiza um layout existente no banco. +/// Zera explicitamente os campos do tipo oposto para evitar dados órfãos. pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> { let id = layout .id() @@ -58,11 +75,13 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> { "UPDATE layouts SET nome = ?1, delimitador = ?2, encoding = ?3, linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6, - indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9 + indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9, + aba = NULL, pos_numero = NULL, pos_serie = NULL, + pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL WHERE id = ?10", params![ nome, - config.delimitador.to_string(), + delim_para_str(config.delimitador), config.encoding, config.linha_cabecalho as i64, config.indice_numero as i64, @@ -78,7 +97,10 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> { conn.execute( "UPDATE layouts SET nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4, - pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7 + pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7, + delimitador = NULL, encoding = NULL, linha_cabecalho = NULL, + indice_numero = NULL, indice_serie = NULL, indice_valor = NULL, + indice_data = NULL, indice_documento_tipo = NULL WHERE id = ?8", params![ nome, @@ -109,25 +131,31 @@ pub fn listar(conn: &Connection) -> Result> { let layouts: Result> = stmt .query_map([], |row| { - let id: i64 = row.get(0)?; - let nome: String = row.get(1)?; - let tipo: String = row.get(2)?; + let id: i64 = row.get("id")?; + let nome: String = row.get("nome")?; + let tipo: String = row.get("tipo")?; if tipo == "csv" { - let delim_str: String = row.get(3)?; - let delimitador = delim_str.chars().next().unwrap_or(';'); + let delim_str: String = row.get("delimitador")?; + let delimitador = str_para_delim(&delim_str); Ok(Layout::Csv { id: Some(id), nome, config: LayoutCsv { delimitador, - encoding: row.get(4)?, - linha_cabecalho: row.get::<_, i64>(5)? as usize, - indice_numero: row.get::<_, i64>(6)? as usize, - indice_serie: row.get::<_, i64>(7)? as usize, - indice_valor: row.get::<_, Option>(8)?.map(|v| v as usize), - indice_data: row.get::<_, Option>(9)?.map(|v| v as usize), - indice_documento_tipo: row.get::<_, Option>(15)?.map(|v| v as usize), + encoding: row.get("encoding")?, + linha_cabecalho: row.get::<_, i64>("linha_cabecalho")? as usize, + indice_numero: row.get::<_, i64>("indice_numero")? as usize, + indice_serie: row.get::<_, i64>("indice_serie")? as usize, + indice_valor: row + .get::<_, Option>("indice_valor")? + .map(|v| v as usize), + indice_data: row + .get::<_, Option>("indice_data")? + .map(|v| v as usize), + indice_documento_tipo: row + .get::<_, Option>("indice_documento_tipo")? + .map(|v| v as usize), }, }) } else { @@ -135,12 +163,12 @@ pub fn listar(conn: &Connection) -> Result> { id: Some(id), nome, config: LayoutXlsx { - aba: row.get(10)?, - pos_numero: row.get(11)?, - pos_serie: row.get(12)?, - pos_valor: row.get(13)?, - pos_data: row.get(14)?, - pos_documento_tipo: row.get(16)?, + aba: row.get("aba")?, + pos_numero: row.get("pos_numero")?, + pos_serie: row.get("pos_serie")?, + pos_valor: row.get("pos_valor")?, + pos_data: row.get("pos_data")?, + pos_documento_tipo: row.get("pos_documento_tipo")?, }, }) } @@ -165,3 +193,14 @@ pub fn existe_nome(conn: &Connection, nome: &str) -> Result { )?; Ok(count > 0) } + +/// Verifica se existe um layout com o nome fornecido, excluindo o registro com o id dado. +/// Usado para validar conflito de nome ao renomear um layout existente. +pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: i64) -> Result { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM layouts WHERE nome = ?1 AND id != ?2", + params![nome, id], + |row| row.get(0), + )?; + Ok(count > 0) +} diff --git a/src/infrastructure/sqlite/migrations.rs b/src/infrastructure/sqlite/migrations.rs index dbfbad5..145ad66 100644 --- a/src/infrastructure/sqlite/migrations.rs +++ b/src/infrastructure/sqlite/migrations.rs @@ -1,8 +1,5 @@ use rusqlite::{Connection, Result}; -/// Versão atual do schema do banco de dados. -const VERSAO_SCHEMA_ATUAL: i64 = 3; - /// Aplica todas as migrations necessárias para atualizar o banco /// para a versão mais recente. pub fn aplicar_migrations(conn: &Connection) -> Result<()> { @@ -29,26 +26,17 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> { migration_v3(conn)?; } - if versao_atual == 0 { - conn.execute( - "INSERT INTO schema_version (versao) VALUES (?1);", - [VERSAO_SCHEMA_ATUAL], - )?; - } else if versao_atual < VERSAO_SCHEMA_ATUAL { - conn.execute( - "UPDATE schema_version SET versao = ?1;", - [VERSAO_SCHEMA_ATUAL], - )?; - } - Ok(()) } /// Migration v3: adicionar colunas de tipo de documento nos layouts. fn migration_v3(conn: &Connection) -> Result<()> { conn.execute_batch( - "ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER; - ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;", + "BEGIN; + ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER; + ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT; + UPDATE schema_version SET versao = 3; + COMMIT;", )?; Ok(()) } @@ -59,12 +47,15 @@ fn migration_v3(conn: &Connection) -> Result<()> { /// em bancos que já possuem nomes repetidos. fn migration_v2(conn: &Connection) -> Result<()> { conn.execute_batch( - "UPDATE layouts - SET nome = nome || ' (' || id || ')' - WHERE id NOT IN ( - SELECT MIN(id) FROM layouts GROUP BY nome - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);", + "BEGIN; + UPDATE layouts + SET nome = nome || ' (' || id || ')' + WHERE id NOT IN ( + SELECT MIN(id) FROM layouts GROUP BY nome + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome); + UPDATE schema_version SET versao = 2; + COMMIT;", )?; Ok(()) } @@ -72,27 +63,30 @@ fn migration_v2(conn: &Connection) -> Result<()> { /// Migration v1: criar tabela de layouts. fn migration_v1(conn: &Connection) -> Result<()> { conn.execute_batch( - "CREATE TABLE IF NOT EXISTS layouts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - nome TEXT NOT NULL, - tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')), + "BEGIN; + CREATE TABLE IF NOT EXISTS layouts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nome TEXT NOT NULL, + tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')), - -- Campos CSV - delimitador TEXT, - encoding TEXT, - linha_cabecalho INTEGER, - indice_numero INTEGER, - indice_serie INTEGER, - indice_valor INTEGER, - indice_data INTEGER, + -- Campos CSV + delimitador TEXT, + encoding TEXT, + linha_cabecalho INTEGER, + indice_numero INTEGER, + indice_serie INTEGER, + indice_valor INTEGER, + indice_data INTEGER, - -- Campos XLSX - aba TEXT, - pos_numero TEXT, - pos_serie TEXT, - pos_valor TEXT, - pos_data TEXT - );", + -- Campos XLSX + aba TEXT, + pos_numero TEXT, + pos_serie TEXT, + pos_valor TEXT, + pos_data TEXT + ); + INSERT INTO schema_version (versao) VALUES (1); + COMMIT;", )?; Ok(()) }