This commit is contained in:
2026-03-04 18:33:32 -03:00
parent 3af14ab957
commit 841dfadde5
6 changed files with 156 additions and 78 deletions
+41 -8
View File
@@ -5,6 +5,17 @@ use crate::domain::{
use crate::infrastructure::sqlite::layout_repository; use crate::infrastructure::sqlite::layout_repository;
use rusqlite::Connection; 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. /// Salva um layout no banco de dados.
/// Se o layout já tem um id, atualiza. Caso contrário, insere. /// Se o layout já tem um id, atualiza. Caso contrário, insere.
/// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome. /// 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<i64, ErroLayo
return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string())); return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string()));
} }
// Validar campos XLSX obrigatórios
if let Layout::Xlsx { config, .. } = layout {
if config.pos_numero.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente(
"pos_numero".to_string(),
));
}
if config.pos_serie.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente("pos_serie".to_string()));
}
}
if let Some(id) = layout.id() { if let Some(id) = layout.id() {
// Verificar conflito de nome com outro layout (excluindo o próprio)
layout_repository::existe_nome_excluindo_id(conn, layout.nome(), id)
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))
.and_then(|conflito| {
if conflito {
Err(ErroLayout::NomeConflitante(layout.nome().to_string()))
} else {
Ok(())
}
})?;
layout_repository::atualizar(conn, layout) layout_repository::atualizar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| mapear_erro_banco(e, layout.nome()))?;
Ok(id) Ok(id)
} else { } else {
let nome = layout.nome().to_string(); let nome = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome) let existe = layout_repository::existe_nome(conn, &nome)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe { if existe {
return Err(ErroLayout::NomeConflitante(nome)); return Err(ErroLayout::NomeConflitante(nome));
} }
layout_repository::salvar(conn, layout) layout_repository::salvar(conn, layout).map_err(|e| mapear_erro_banco(e, layout.nome()))
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
} }
} }
@@ -79,13 +112,13 @@ pub fn importar_layout_json(
// Verificar conflito de nome // Verificar conflito de nome
let nome_atual = layout.nome().to_string(); let nome_atual = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome_atual) let existe = layout_repository::existe_nome(conn, &nome_atual)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe { if existe {
if sobrescrever_se_existir { if sobrescrever_se_existir {
// Buscar o id existente para sobrescrever // Buscar o id existente para sobrescrever
let layouts_existentes = layout_repository::listar(conn) let layouts_existentes = layout_repository::listar(conn)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
let id_existente = layouts_existentes let id_existente = layouts_existentes
.iter() .iter()
@@ -98,7 +131,7 @@ pub fn importar_layout_json(
Layout::Xlsx { id: i, .. } => *i = Some(id), Layout::Xlsx { id: i, .. } => *i = Some(id),
} }
layout_repository::atualizar(conn, &layout) 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); return Ok(id);
} }
} else { } else {
@@ -107,5 +140,5 @@ pub fn importar_layout_json(
} }
// Inserir novo // 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))
} }
+2
View File
@@ -24,6 +24,8 @@ pub enum ErroLayout {
JsonMalformado(String), JsonMalformado(String),
#[error("Conflito de nome: layout '{0}' já existe")] #[error("Conflito de nome: layout '{0}' já existe")]
NomeConflitante(String), NomeConflitante(String),
#[error("Erro no banco de dados: {0}")]
ErroBanco(String),
} }
#[derive(Debug, Error, Clone)] #[derive(Debug, Error, Clone)]
+14 -4
View File
@@ -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. `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). 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 ## migrations.rs
@@ -88,7 +90,11 @@ Fluxo:
- `versao_atual < 1``migration_v1` - `versao_atual < 1``migration_v1`
- `versao_atual < 2``migration_v2` - `versao_atual < 2``migration_v2`
- `versao_atual < 3``migration_v3` - `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 ### 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 Atualiza um layout existente pelo `id` embutido na variante. Retorna erro se
`id` for `None`. `id` for `None`.
- `Layout::Csv` → atualiza apenas as colunas CSV. - `Layout::Csv` → atualiza apenas as colunas CSV; colunas XLSX ficam inalteradas no banco.
- `Layout::Xlsx` → atualiza apenas as colunas XLSX. - `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<Vec<Layout>>` #### `listar(conn) -> Result<Vec<Layout>>`
@@ -161,6 +169,8 @@ Seleciona todos os layouts ordenados por `nome ASC`. Para cada linha:
Campos opcionais (`Option<usize>`) são lidos como `Option<i64>` e convertidos. Campos opcionais (`Option<usize>`) são lidos como `Option<i64>` 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<()>` #### `excluir(conn, id) -> Result<()>`
Remove o registro com o `id` informado via `DELETE`. Remove o registro com o `id` informado via `DELETE`.
@@ -187,7 +197,7 @@ aplicar_migrations(conn)
├─ cria schema_version se necessário ├─ cria schema_version se necessário
├─ lê versão atual ├─ lê versão atual
├─ executa migrations pendentes (v1 → v2 → v3) ├─ 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`. Após esse fluxo, a conexão está pronta para uso pelo `layout_repository`.
+2 -2
View File
@@ -28,8 +28,8 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
if caminho.exists() { if caminho.exists() {
match Connection::open(caminho) { match Connection::open(caminho) {
Ok(conn) => { Ok(conn) => {
// Testar se o banco funciona com uma query simples // Testar integridade estrutural do banco
match conn.execute_batch("SELECT 1;") { match conn.execute_batch("PRAGMA quick_check;") {
Ok(_) => return Ok((conn, false)), Ok(_) => return Ok((conn, false)),
Err(_e) => { Err(_e) => {
// Banco corrompido // Banco corrompido
+61 -22
View File
@@ -1,6 +1,22 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx}; use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
use rusqlite::{params, Connection, Result}; 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. /// Salva um layout no banco. Retorna o id gerado.
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> { pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
match layout { match layout {
@@ -13,7 +29,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![ params![
nome, nome,
config.delimitador.to_string(), delim_para_str(config.delimitador),
config.encoding, config.encoding,
config.linha_cabecalho as i64, config.linha_cabecalho as i64,
config.indice_numero as i64, config.indice_numero as i64,
@@ -47,6 +63,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
} }
/// Atualiza um layout existente no banco. /// 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<()> { pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
let id = layout let id = layout
.id() .id()
@@ -58,11 +75,13 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
"UPDATE layouts SET "UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3, nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6, 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", WHERE id = ?10",
params![ params![
nome, nome,
config.delimitador.to_string(), delim_para_str(config.delimitador),
config.encoding, config.encoding,
config.linha_cabecalho as i64, config.linha_cabecalho as i64,
config.indice_numero as i64, config.indice_numero as i64,
@@ -78,7 +97,10 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
conn.execute( conn.execute(
"UPDATE layouts SET "UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4, 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", WHERE id = ?8",
params![ params![
nome, nome,
@@ -109,25 +131,31 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
let layouts: Result<Vec<Layout>> = stmt let layouts: Result<Vec<Layout>> = stmt
.query_map([], |row| { .query_map([], |row| {
let id: i64 = row.get(0)?; let id: i64 = row.get("id")?;
let nome: String = row.get(1)?; let nome: String = row.get("nome")?;
let tipo: String = row.get(2)?; let tipo: String = row.get("tipo")?;
if tipo == "csv" { if tipo == "csv" {
let delim_str: String = row.get(3)?; let delim_str: String = row.get("delimitador")?;
let delimitador = delim_str.chars().next().unwrap_or(';'); let delimitador = str_para_delim(&delim_str);
Ok(Layout::Csv { Ok(Layout::Csv {
id: Some(id), id: Some(id),
nome, nome,
config: LayoutCsv { config: LayoutCsv {
delimitador, delimitador,
encoding: row.get(4)?, encoding: row.get("encoding")?,
linha_cabecalho: row.get::<_, i64>(5)? as usize, linha_cabecalho: row.get::<_, i64>("linha_cabecalho")? as usize,
indice_numero: row.get::<_, i64>(6)? as usize, indice_numero: row.get::<_, i64>("indice_numero")? as usize,
indice_serie: row.get::<_, i64>(7)? as usize, indice_serie: row.get::<_, i64>("indice_serie")? as usize,
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize), indice_valor: row
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize), .get::<_, Option<i64>>("indice_valor")?
indice_documento_tipo: row.get::<_, Option<i64>>(15)?.map(|v| v as usize), .map(|v| v as usize),
indice_data: row
.get::<_, Option<i64>>("indice_data")?
.map(|v| v as usize),
indice_documento_tipo: row
.get::<_, Option<i64>>("indice_documento_tipo")?
.map(|v| v as usize),
}, },
}) })
} else { } else {
@@ -135,12 +163,12 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
id: Some(id), id: Some(id),
nome, nome,
config: LayoutXlsx { config: LayoutXlsx {
aba: row.get(10)?, aba: row.get("aba")?,
pos_numero: row.get(11)?, pos_numero: row.get("pos_numero")?,
pos_serie: row.get(12)?, pos_serie: row.get("pos_serie")?,
pos_valor: row.get(13)?, pos_valor: row.get("pos_valor")?,
pos_data: row.get(14)?, pos_data: row.get("pos_data")?,
pos_documento_tipo: row.get(16)?, pos_documento_tipo: row.get("pos_documento_tipo")?,
}, },
}) })
} }
@@ -165,3 +193,14 @@ pub fn existe_nome(conn: &Connection, nome: &str) -> Result<bool> {
)?; )?;
Ok(count > 0) 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<bool> {
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)
}
+15 -21
View File
@@ -1,8 +1,5 @@
use rusqlite::{Connection, Result}; 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 /// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente. /// para a versão mais recente.
pub fn aplicar_migrations(conn: &Connection) -> Result<()> { pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
@@ -29,26 +26,17 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
migration_v3(conn)?; 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(()) Ok(())
} }
/// Migration v3: adicionar colunas de tipo de documento nos layouts. /// Migration v3: adicionar colunas de tipo de documento nos layouts.
fn migration_v3(conn: &Connection) -> Result<()> { fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER; "BEGIN;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;", 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(()) Ok(())
} }
@@ -59,12 +47,15 @@ fn migration_v3(conn: &Connection) -> Result<()> {
/// em bancos que já possuem nomes repetidos. /// em bancos que já possuem nomes repetidos.
fn migration_v2(conn: &Connection) -> Result<()> { fn migration_v2(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
"UPDATE layouts "BEGIN;
UPDATE layouts
SET nome = nome || ' (' || id || ')' SET nome = nome || ' (' || id || ')'
WHERE id NOT IN ( WHERE id NOT IN (
SELECT MIN(id) FROM layouts GROUP BY nome SELECT MIN(id) FROM layouts GROUP BY nome
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);", CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
UPDATE schema_version SET versao = 2;
COMMIT;",
)?; )?;
Ok(()) Ok(())
} }
@@ -72,7 +63,8 @@ fn migration_v2(conn: &Connection) -> Result<()> {
/// Migration v1: criar tabela de layouts. /// Migration v1: criar tabela de layouts.
fn migration_v1(conn: &Connection) -> Result<()> { fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS layouts ( "BEGIN;
CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL, nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')), tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
@@ -92,7 +84,9 @@ fn migration_v1(conn: &Connection) -> Result<()> {
pos_serie TEXT, pos_serie TEXT,
pos_valor TEXT, pos_valor TEXT,
pos_data TEXT pos_data TEXT
);", );
INSERT INTO schema_version (versao) VALUES (1);
COMMIT;",
)?; )?;
Ok(()) Ok(())
} }