Compare commits

...
2 Commits
Author SHA1 Message Date
Felipe 857fcd6a82 Fix SalvarLayoutConfig 2026-03-04 21:45:52 -03:00
Felipe 841dfadde5 update 2026-03-04 18:33:32 -03:00
8 changed files with 163 additions and 442 deletions
-353
View File
@@ -1,353 +0,0 @@
# FIX — Salvar Layout no Banco de Dados
**Data:** 04/03/2026
**Versão do PRD:** 1.7
**Status:** Pendente de Implementação
---
## Sumário dos Problemas
| # | Severidade | Arquivo(s) | Descrição |
|---|-----------|------------|-----------|
| 1 | **Crítico** | `migrations.rs` | `migration_v3` sem transação — falha parcial deixa banco permanentemente quebrado |
| 2 | **Crítico** | `migrations.rs` | `schema_version` atualizada fora da transação das migrations |
| 3 | **Crítico** | `usecases/layouts.rs` | Erros SQL mapeados como `ErroLayout::JsonMalformado` — mensagem completamente enganosa |
| 4 | Moderado | `layout_repository.rs` | `delimitador` TAB salvo como byte de controle invisível no banco |
| 5 | Moderado | `usecases/layouts.rs` | `pos_numero` / `pos_serie` XLSX podem ser strings vazias — sem validação no fluxo de save da UI |
| 6 | Moderado | `layout_repository.rs` | Índices posicionais hardcoded em `row.get(N)` — quebra silenciosa se colunas do SELECT forem reordenadas |
| 7 | Menor | `usecases/layouts.rs` + `layout_repository.rs` | Renomear layout para nome já existente (UPDATE) gera `JsonMalformado` em vez de `NomeConflitante` |
| 8 | Menor | `connection.rs` | `SELECT 1` não detecta corrupção real de páginas SQLite |
| 9 | Menor | `layout_repository.rs` | UPDATE não zera campos do tipo oposto ao tipo atual do layout |
---
## Problema 1 (Crítico) — `migration_v3` sem transação atômica
### Local
`src/infrastructure/sqlite/migrations.rs` — função `migration_v3`
### Situação Atual
```rust
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;",
)?;
Ok(())
}
```
`execute_batch` executa os dois `ALTER TABLE` sem transação explícita. Se o processo for interrompido após o primeiro e antes do segundo, o banco fica em estado parcial:
- `indice_documento_tipo` existe, `pos_documento_tipo` não existe.
- A versão no banco não é atualizada (o erro propaga antes).
- Na próxima execução, `migration_v3` tenta adicionar `indice_documento_tipo` novamente → SQLite retorna `"duplicate column name"`**o app nunca mais inicializa sem intervenção manual**.
### Correção Necessária
Envolver cada migration em uma transação explícita e atualizar `schema_version` **dentro da mesma transação**:
```rust
fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch("
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(())
}
```
> **Nota:** O SQLite suporta `ALTER TABLE` dentro de transação explícita desde a versão 3.x. `execute_batch` executa múltiplos statements quando delimitados por `;` dentro do mesmo bloco `BEGIN/COMMIT`.
---
## Problema 2 (Crítico) — `schema_version` atualizada fora da transação das migrations
### Local
`src/infrastructure/sqlite/migrations.rs` — função `aplicar_migrations`
### Situação Atual
O fluxo atual é:
1. Executa `migration_v1(conn)?`
2. Executa `migration_v2(conn)?`
3. Executa `migration_v3(conn)?`
4. **Depois** executa `INSERT OR REPLACE INTO schema_version ...`
Se qualquer migration falhar após outras já terem sido aplicadas, a versão não é atualizada, causando re-execução problemática na próxima abertura.
### Correção Necessária
Cada migration deve atualizar `schema_version` internamente (dentro de sua própria transação), como mostrado no Problema 1. A função `aplicar_migrations` não deve mais atualizar a versão centralmente — ela apenas chama as migrations que ainda não foram aplicadas.
Adicionalmente, as migrations v1 e v2 devem seguir o mesmo padrão:
```rust
fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch("
BEGIN;
CREATE TABLE IF NOT EXISTS layouts ( ... );
INSERT OR REPLACE INTO schema_version (id, versao) VALUES (1, 1);
COMMIT;
")?;
Ok(())
}
fn migration_v2(conn: &Connection) -> Result<()> {
conn.execute_batch("
BEGIN;
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
-- renomeia duplicatas existentes se houver
UPDATE schema_version SET versao = 2;
COMMIT;
")?;
Ok(())
}
```
---
## Problema 3 (Crítico) — Erros SQL mapeados como `ErroLayout::JsonMalformado`
### Local
`src/application/usecases/layouts.rs` — função `salvar_layout` e `atualizar_layout`
### Situação Atual
```rust
layout_repository::salvar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
layout_repository::atualizar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
```
Qualquer erro do banco de dados (violação de constraint, coluna ausente, banco travado) é apresentado ao usuário como **"JSON malformado"**, o que é completamente incorreto. Exemplos de mensagens enganosas que o usuário veria:
- `"UNIQUE constraint failed: layouts.nome"` → exibido como "JSON malformado"
- `"no such column: pos_documento_tipo"` → exibido como "JSON malformado"
- `"attempt to write a readonly database"` → exibido como "JSON malformado"
### Correção Necessária
1. Adicionar variante própria em `domain/errors.rs`:
```rust
pub enum ErroLayout {
// ... variantes existentes ...
ErroBanco(String), // erros genéricos de I/O do banco
NomeConflitante, // já existe (para uso tanto em INSERT quanto em UPDATE)
}
```
2. No use case, inspecionar o erro antes de mapear:
```rust
layout_repository::salvar(conn, layout)
.map_err(|e| {
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") {
ErroLayout::NomeConflitante
} else {
ErroLayout::ErroBanco(msg)
}
})?;
```
3. Na UI (`ui/screens/layouts.rs`), tratar `ErroBanco` com mensagem clara ao usuário: `"Erro ao salvar no banco de dados: {mensagem}"`.
---
## Problema 4 (Moderado) — `delimitador` TAB salvo como byte de controle no banco
### Local
`src/infrastructure/sqlite/layout_repository.rs` — funções `salvar` e `atualizar` (CSV)
### Situação Atual
O `char` `'\t'` é convertido para `String` via `.to_string()`, armazenando um caractere de tabulação literal (byte `0x09`) no campo `TEXT` do banco. Embora funcional, é opaco para inspeção manual do banco e incompatível com exports/backups que não preservam bytes de controle.
### Correção Necessária
Normalizar o delimitador para um token legível antes de salvar:
```rust
// Ao salvar:
let delim_str = match config.delimitador {
'\t' => "tab".to_string(),
c => c.to_string(),
};
// Ao ler:
let delimitador = match delim_str.as_str() {
"tab" => '\t',
s => s.chars().next().unwrap_or(';'),
};
```
> Isso também melhora a legibilidade dos arquivos JSON de export/import de layouts.
---
## Problema 5 (Moderado) — `pos_numero` e `pos_serie` XLSX sem validação no fluxo de save da UI
### Local
`src/application/usecases/layouts.rs` — função `salvar_layout`
### Situação Atual
A validação atual verifica apenas que `nome` não esteja vazio. Os campos `pos_numero` e `pos_serie` de layouts XLSX são `String` obrigatórias (não `Option<String>`), mas podem ser strings vazias `""`. O banco os aceita sem restrição, e o erro só apareceria na análise, sem indicar que o layout está incompleto.
### Correção Necessária
Expandir a validação no use case `salvar_layout`:
```rust
match layout {
Layout::Csv { config, .. } => {
// indice_numero e indice_serie são usize — sempre válidos se presentes
// nenhuma validação adicional necessária aqui
}
Layout::Xlsx { config, .. } => {
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()));
}
}
}
```
---
## Problema 6 (Moderado) — Índices posicionais hardcoded em `row.get(N)`
### Local
`src/infrastructure/sqlite/layout_repository.rs` — função `listar`
### Situação Atual
O mapeamento usa índices numéricos (`row.get(0)`, `row.get(1)`, ..., `row.get(16)`). Qualquer reordenação das colunas no `SELECT` quebra silenciosamente o mapeamento sem erro de compilação.
### Correção Necessária
Substituir por nomes de colunas usando `row.get::<_, T>(nome_coluna)`:
```rust
// Em vez de:
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
// Usar:
let id: i64 = row.get("id")?;
let nome: String = row.get("nome")?;
```
O `rusqlite` suporta `row.get("nome_coluna")` desde a versão 0.26. A versão usada no projeto é 0.32, portanto compatível.
---
## Problema 7 (Menor) — Renomear para nome conflitante em UPDATE gera mensagem errada
### Local
`src/application/usecases/layouts.rs` + `src/infrastructure/sqlite/layout_repository.rs`
### Situação Atual
No fluxo de UPDATE (re-save de layout existente), não há verificação de conflito de nome antes de chamar `atualizar`. Se o usuário renomear um layout para um nome já em uso, a constraint `UNIQUE` do banco rejeita o UPDATE, o erro é mapeado como `JsonMalformado` (Problema 3 acima).
### Correção Necessária
No use case `atualizar_layout`, verificar se o novo nome conflita com outro layout (excluindo o próprio ID):
```rust
// Verificar conflito excluindo o próprio registro
if layout_repository::existe_nome_excluindo_id(conn, layout.nome(), layout.id())? {
return Err(ErroLayout::NomeConflitante);
}
```
Adicionar função no repository:
```rust
pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: Option<i64>) -> Result<bool> {
match id {
Some(id) => {
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)
}
None => layout_repository::existe_nome(conn, nome),
}
}
```
---
## Problema 8 (Menor) — `SELECT 1` não detecta corrupção real do banco
### Local
`src/infrastructure/sqlite/connection.rs`
### Situação Atual
```rust
match conn.execute_batch("SELECT 1;") {
Ok(_) => return Ok((conn, false)),
Err(_) => { /* trata como banco corrompido */ }
}
```
`SELECT 1` não acessa nenhuma página de dados do SQLite. Um banco com tabelas corrompidas, índices inválidos ou páginas com checksum errado passaria nessa verificação sem ser detectado, causando erros inesperados posteriormente.
### Correção Necessária
```rust
match conn.execute_batch("PRAGMA quick_check;") {
Ok(_) => return Ok((conn, false)),
Err(_) => { /* banco corrompido */ }
}
```
`PRAGMA quick_check` verifica a integridade estrutural do banco (sem verificar cada valor de dado, como `integrity_check` faz). É mais rápido que `integrity_check` e muito mais confiável que `SELECT 1`.
---
## Problema 9 (Menor) — UPDATE não zera campos do tipo oposto
### Local
`src/infrastructure/sqlite/layout_repository.rs` — função `atualizar`
### Situação Atual
O UPDATE de CSV não zera campos XLSX (`aba`, `pos_numero`, etc.) e o UPDATE de XLSX não zera campos CSV. Na prática não ocorre troca de tipo, mas se ocorrer (via importação JSON com mesmo nome e tipo diferente + sobrescrita), os campos do tipo anterior ficam no banco.
### Correção Necessária
Adicionar `SET campo = NULL` explícito para os campos do tipo oposto em cada UPDATE:
```sql
-- UPDATE CSV: zerar campos XLSX
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,
-- zerar campos XLSX:
aba = NULL, pos_numero = NULL, pos_serie = NULL,
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
WHERE id = ?10
```
```sql
-- UPDATE XLSX: zerar campos CSV
UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
-- zerar campos CSV:
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
```
---
## Ordem de Implementação Recomendada
1. **Problema 1 + 2** (migrations com transação) — Risco de banco permanentemente inutilizável; implementar primeiro.
2. **Problema 3** (mapeamento de erros) — Sem isso o usuário não entende o que está errado.
3. **Problema 5** (validação de campos XLSX) — Evita layouts inválidos serem salvos.
4. **Problema 7** (conflito de nome em UPDATE) — Depende da solução do Problema 3.
5. **Problema 4** (delimitador TAB) — Melhoria de robustez; não causa crash.
6. **Problema 6** (índices posicionais) — Refactor preventivo.
7. **Problema 8** (PRAGMA quick_check) — Melhoria de confiabilidade.
8. **Problema 9** (zerar campos opostos) — Limpeza defensiva.
+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)
}
+36 -42
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;
SET nome = nome || ' (' || id || ')' UPDATE layouts
WHERE id NOT IN ( SET nome = nome || ' (' || id || ')'
SELECT MIN(id) FROM layouts GROUP BY nome 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(()) Ok(())
} }
@@ -72,27 +63,30 @@ 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;
id INTEGER PRIMARY KEY AUTOINCREMENT, CREATE TABLE IF NOT EXISTS layouts (
nome TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')), nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
-- Campos CSV -- Campos CSV
delimitador TEXT, delimitador TEXT,
encoding TEXT, encoding TEXT,
linha_cabecalho INTEGER, linha_cabecalho INTEGER,
indice_numero INTEGER, indice_numero INTEGER,
indice_serie INTEGER, indice_serie INTEGER,
indice_valor INTEGER, indice_valor INTEGER,
indice_data INTEGER, indice_data INTEGER,
-- Campos XLSX -- Campos XLSX
aba TEXT, aba TEXT,
pos_numero TEXT, pos_numero TEXT,
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(())
} }
+7 -11
View File
@@ -614,9 +614,11 @@ impl App {
let modal = self.modal.take(); let modal = self.modal.take();
if let Some(estado) = modal { if let Some(estado) = modal {
match estado { match estado {
EstadoModal::Confirmacao { acao, .. } EstadoModal::Confirmacao { acao, .. } => {
| EstadoModal::InputTexto { acao, .. } => { return self.executar_acao_modal(acao, None);
return self.executar_acao_modal(acao); }
EstadoModal::InputTexto { acao, texto, .. } => {
return self.executar_acao_modal(acao, Some(texto));
} }
_ => {} _ => {}
} }
@@ -991,7 +993,7 @@ impl App {
} }
} }
fn executar_acao_modal(&mut self, acao: AcaoModal) -> Task<Message> { fn executar_acao_modal(&mut self, acao: AcaoModal, texto_input: Option<String>) -> Task<Message> {
match acao { match acao {
AcaoModal::ConfirmarExpansaoFaltantes => { AcaoModal::ConfirmarExpansaoFaltantes => {
self.update(Message::ConfirmarExpansaoFaltantes) self.update(Message::ConfirmarExpansaoFaltantes)
@@ -1009,13 +1011,7 @@ impl App {
Task::none() Task::none()
} }
AcaoModal::SalvarLayoutConfig => { AcaoModal::SalvarLayoutConfig => {
let texto = if let Some(EstadoModal::InputTexto { texto, .. }) = &self.modal { let nome = texto_input.unwrap_or_default().trim().to_string();
texto.clone()
} else {
String::new()
};
self.modal = None;
let nome = texto.trim().to_string();
if nome.is_empty() { if nome.is_empty() {
self.exibir_erro("O nome do layout não pode ser vazio."); self.exibir_erro("O nome do layout não pode ser vazio.");
return Task::none(); return Task::none();