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 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<i64, ErroLayo
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() {
// 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)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
.map_err(|e| mapear_erro_banco(e, layout.nome()))?;
Ok(id)
} else {
let nome = layout.nome().to_string();
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 {
return Err(ErroLayout::NomeConflitante(nome));
}
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, layout.nome()))
}
}
@@ -79,13 +112,13 @@ pub fn importar_layout_json(
// Verificar conflito de nome
let nome_atual = layout.nome().to_string();
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 sobrescrever_se_existir {
// Buscar o id existente para sobrescrever
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
.iter()
@@ -98,7 +131,7 @@ pub fn importar_layout_json(
Layout::Xlsx { id: i, .. } => *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))
}
+2
View File
@@ -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)]
+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.
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<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.
> **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`.
+2 -2
View File
@@ -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
+61 -22
View File
@@ -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<i64> {
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)",
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<i64> {
}
/// 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<Vec<Layout>> {
let layouts: Result<Vec<Layout>> = 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<i64>>(8)?.map(|v| v as usize),
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
indice_documento_tipo: row.get::<_, Option<i64>>(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<i64>>("indice_valor")?
.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 {
@@ -135,12 +163,12 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
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<bool> {
)?;
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};
/// 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
"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);",
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
UPDATE schema_version SET versao = 2;
COMMIT;",
)?;
Ok(())
}
@@ -72,7 +63,8 @@ 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 (
"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')),
@@ -92,7 +84,9 @@ fn migration_v1(conn: &Connection) -> Result<()> {
pos_serie TEXT,
pos_valor TEXT,
pos_data TEXT
);",
);
INSERT INTO schema_version (versao) VALUES (1);
COMMIT;",
)?;
Ok(())
}