QuickSearch — Busca rápida com normalização editar arquivo

0:00 / 0:00

O usuário digita na barra de busca e a lista filtra em tempo real, sem clicar em botão. A busca é fuzzy: açúcar acha Açúcar Refinado, acucar acha igual, AÇÚCAR também — tudo via uma coluna dedicada no banco com texto pré-normalizado (lowercase, sem acento).

5 peças trabalhando juntas: coluna nova no banco, campo no Bean, normalização no DAO, inner class QuickSearch que recebe o input do usuário, e a barra visual com ui().bgDark() no window().

CÓDIGO COMPLETO — DepartamentoProdutoBean
package br.xt.app.departamento.produto;

import br.jasap.dao.DBInfo;
import java.io.Serializable;

public class DepartamentoProdutoBean implements Serializable {

    public static String TABLE = "departamento.produto";

    private Integer id_produto;
    private String  nome_produto;
    private Double  vl_produto;
    private Integer qtd_produto;
    private String  obs_produto;
    private String  qs_produto;

    @DBInfo(serial=true, pk=true)
    public Integer getId_produto() { return id_produto; }
    public void setId_produto(Integer id_produto) { this.id_produto = id_produto; }

    public String getNome_produto() { return nome_produto; }
    public void setNome_produto(String nome_produto) { this.nome_produto = nome_produto; }

    public Double getVl_produto() { return vl_produto; }
    public void setVl_produto(Double vl_produto) { this.vl_produto = vl_produto; }

    public Integer getQtd_produto() { return qtd_produto; }
    public void setQtd_produto(Integer qtd_produto) { this.qtd_produto = qtd_produto; }

    public String getObs_produto() { return obs_produto; }
    public void setObs_produto(String obs_produto) { this.obs_produto = obs_produto; }

    public String getQs_produto() { return qs_produto; }
    public void setQs_produto(String qs_produto) { this.qs_produto = qs_produto; }

    public static String ID_PRODUTO   = "id_produto";
    public static String NOME_PRODUTO = "nome_produto";
    public static String VL_PRODUTO   = "vl_produto";
    public static String QTD_PRODUTO  = "qtd_produto";
    public static String OBS_PRODUTO  = "obs_produto";
    public static String QS_PRODUTO   = "qs_produto";

}

Mudanças nesta sequência: 1 campo, 1 par getter/setter e 1 constante. Nenhum import novo — o framework trata qs_produto como qualquer outra coluna do tipo String.

CÓDIGO COMPLETO — DepartamentoProdutoDAO
package br.xt.app.departamento.produto;

import br.jasap.core.AppManager;
import br.jasap.dao.Query;
import br.jasap.dao.SQL;
import br.jasap.util.JasapFunctions;
import br.jasap.util.JasapList;
import br.xt.AppsRootDAO;

public class DepartamentoProdutoDAO extends AppsRootDAO {

    public DepartamentoProdutoDAO() {
    }

    public DepartamentoProdutoDAO(AppManager manager) {
        setManager(manager);
        setDataBase(manager.getDataBase());
    }

    public void daoList(JasapList list) throws Exception {
        StringBuilder sql = new StringBuilder();
        sql.append("select a.* from " + DepartamentoProdutoBean.TABLE + " a");
        sql.append(" where 1=1 <where> <orderby>");

        Query query = getDataBase().getQuery(sql.toString());
        daoWhere(list.getFiltro());
        select().executeList(query, list);

        while (query.next()) {
            DepartamentoProdutoBean bean = new DepartamentoProdutoBean();
            query.populateBean(bean);
            list.getList().add(bean);
        }
        query.release();
    }

    public void daoInsert(DepartamentoProdutoBean bean) throws Exception {
        qs_produto(bean);
        insert().execute(bean, DepartamentoProdutoBean.TABLE);
    }

    public void daoSingle(DepartamentoProdutoBean bean) throws Exception {
        select().execute(bean, DepartamentoProdutoBean.TABLE);
    }

    public void daoUpdate(DepartamentoProdutoBean bean) throws Exception {
        qs_produto(bean);
        update().execute(bean, DepartamentoProdutoBean.TABLE);
    }

    public void daoDelete(DepartamentoProdutoBean bean) throws Exception {
        delete().execute(bean, DepartamentoProdutoBean.TABLE);
    }

    public void qs_produto(DepartamentoProdutoBean bean) throws Exception {
        bean.setQs_produto(JasapFunctions.searchString(
                bean.getNome_produto() + " " +
                bean.getObs_produto()
        ));
    }

    public void daoWhere(Object objWhere) throws Exception {
        String where = "";
        if (objWhere != null) {
            DepartamentoProdutoWBean filtro = (DepartamentoProdutoWBean) objWhere;

            if (filtro.getQs_produto() != null && !JasapFunctions.equals(filtro.getQs_produto(), "")) {
                String[] cols = { SQL.column(DepartamentoProdutoBean.QS_PRODUTO) };
                where = where + qsWhere(filtro.getQs_produto(), cols);
            }

            filtro.setWhere(where);
        }
    }

}

Mudanças nesta sequência: 2 imports novos (SQL, JasapFunctions), 1 marcador <where> no SQL do daoList, 1 chamada de daoWhere, chamadas de qs_produto(bean) no daoInsert e daoUpdate, e os 2 métodos novos no fim (qs_produto e daoWhere).

CÓDIGO COMPLETO — DepartamentoProdutoList
package br.xt.app.departamento.produto;

import br.jasap.core.Effect;
import br.jasap.effect.Response;
import br.jasap.gui.JasapPage;
import br.jasap.gui.ListColumn;
import br.jasap.gui.ListLine;
import br.jasap.gui.ListView;
import br.jasap.gui.Bar;
import br.jasap.gui.Button;
import br.jasap.gui.Table;
import br.jasap.gui.Toast;
import br.jasap.gui.form.Text;
import br.jasap.util.JasapFunctions;
import br.jasap.util.Js;
import br.jasap.util.ModalConfig;
import br.jasap.util.exceptions.SQLConstraintException;
import br.xt.acore.view.IconButton;
import br.xt.acore.view.XtPage;

public class DepartamentoProdutoList extends DepartamentoProdutoAction {

    // ... execute() e render() (sem alteração)

    public Table window() throws Exception {
        Table w = new Table(getManager()).setSize("100%", "100%");
        w.rowC("99%", JasapPage.DIV_WSPACE, lView());
        w.rowC("1%",  null, ui().line());
        w.rowC("1%",  null, qs_produto());
        w.rowC("1%",  JasapPage.DIV_BOTTOM, br());
        w.rowC("1%",  null, ui().line());
        return w;
    }

    protected Text qs_produto = null;
    public String qs_produto() throws Exception {
        qs_produto = ui().text(DepartamentoProdutoBean.QS_PRODUTO)
                .setLabel("Consulta")
                .setStyle("width:400;height:30px;font-size:14px")
                .setMaxlength(300)
                .setValue(getFiltro().getQs_produto())
                .setOnkeyup(Js.pressEnter(
                        link(QuickSearch.class)
                                .putScript(DepartamentoProdutoBean.QS_PRODUTO, Js.SELF_VALUE)
                                .ajax()));

        Table aux = new Table(getManager(), "100%", "45")
                .setStyle(ui().bgDark())
                .rowC()
                .setAlign(Table.ALIGN_CENTER)
                .setVerticalAlign(Table.ALIGN_MIDDLE)
                .setContent(qs_produto.toHtml() + "<span class=\"glyphicon glyphicon-search xt-ico-white\"></span>")
                .table();

        return aux.toHtml();
    }

    // ... br(), lView(), getFiltro() (sem alteração)

    public static class Sort extends DepartamentoProdutoList {
        @Override
        public Effect execute() throws Exception {
            update(lView().getDIV_HEADER(), lView().getHeader());
            update(lView().getDIV_BODY(), lView().getBody());
            update(lView().getDIV_NAVIGATE(), lView().getNavForm());
            return new Response();
        }
    }

    public static class QuickSearch extends DepartamentoProdutoList {
        @Override
        public Effect execute() throws Exception {
            getFiltro().setQs_produto(getInput().getString(DepartamentoProdutoBean.QS_PRODUTO));
            update(lView().getDIV_BODY(), lView().getBody());
            update(lView().getDIV_NAVIGATE(), lView().getNavForm());
            eval(Js.setFocusTo(DepartamentoProdutoBean.QS_PRODUTO));
            return new Response();
        }
    }

    // ... DeleteFromList (sem alteração)

    public static final String LIST         = ROOT.concat("__LIST/");
    public static final String FILTRO       = LIST.concat("__FILTRO");
    public static final String CONFIRM_LIST = LIST.concat("__CONFIRM_LIST");

}

Mudanças nesta sequência: 1 import (Text), 1 linha nova no window() (a barra de busca), o método qs_produto() com a barra visual, e a inner class QuickSearch.

CÓDIGO COMPLETO — DepartamentoManager
package br.xt.app.departamento;

import br.xt.app.departamento.produto.DepartamentoProdutoForm;
import br.xt.app.departamento.produto.DepartamentoProdutoList;
import br.xt.app.painel.PnlManager;

public class DepartamentoManager extends PnlManager {

    public static final String F_ACESSO_MODULO = "XT.PAINEL_CONTROLE.ACESSO_MODULO.DEPARTAMENTO";

    @Override
    public void config() throws Exception {

        regFun("PAINEL DE CONTROLE", "Acesso ao Módulo", "DEPARTAMENTO", F_ACESSO_MODULO);

        regAction(DepartamentoHome.class);
        regAction(DepartamentoHome.Title.class);
        regAction(DepartamentoHome.MenuItem.class);
        regAction(DepartamentoHome.MenuInicial.class);

        regAction(DepartamentoProdutoList.class);
        regAction(DepartamentoProdutoList.Sort.class);
        regAction(DepartamentoProdutoList.QuickSearch.class);
        regAction(DepartamentoProdutoList.DeleteFromList.class);

        regAction(DepartamentoProdutoForm.class);
        regAction(DepartamentoProdutoForm.ShowInsert.class);
        regAction(DepartamentoProdutoForm.ShowUpdate.class);
        regAction(DepartamentoProdutoForm.Insert.class);
        regAction(DepartamentoProdutoForm.Update.class);
        regAction(DepartamentoProdutoForm.Cancelar.class);

    }
}

Mudança nesta sequência: 1 linha regAction nova. Nenhum import novo — DepartamentoProdutoList já era importado.

SQL — coluna qs_produto no banco novo
ALTER TABLE departamento.produto ADD COLUMN qs_produto text;

CREATE EXTENSION IF NOT EXISTS unaccent;

UPDATE departamento.produto
SET qs_produto = lower(unaccent(coalesce(nome_produto, '') || ' ' || coalesce(obs_produto, '')));

Três comandos no PostgreSQL:

  • ALTER TABLE — cria a coluna qs_produto do tipo text. Obrigatório: sem essa coluna, o primeiro INSERT ou UPDATE de produto vai dar erro column "qs_produto" does not exist. O framework Jasap inclui automaticamente todos os campos do Bean no SQL.
  • CREATE EXTENSION unaccent — habilita a extensão do PostgreSQL que remove acentos. Necessária pro UPDATE abaixo. Roda só uma vez por banco.
  • UPDATE — popula qs_produto nos produtos que já existem antes da feature entrar. Opcional: produtos novos serão populados automaticamente pelo método qs_produto(bean) no DAO. Sem rodar este UPDATE, os produtos antigos não vão aparecer na busca até serem editados.

O UPDATE faz o equivalente Java do JasapFunctions.searchString(): lower + unaccent + concatenação. Resultado: "Açúcar Refinado 1kg" vira "acucar refinado 1kg".

O mecanismo — 5 peças que fazem o QuickSearch funcionar

Buscar com normalização exige 5 partes coordenadas. Se qualquer uma faltar, ou a busca não funciona, ou o app quebra:

PeçaOndePapel
Coluna qs_produtobanco (PostgreSQL)Guarda o texto pesquisável já normalizado (lower + sem acento)
Campo qs_produtoBeanMapeia a coluna do banco — getter/setter + constante
NormalizaçãoDAO (qs_produto, daoWhere)Monta o texto normalizado no INSERT/UPDATE; filtra com qsWhere no SELECT
Inner class QuickSearchListRecebe o texto digitado, salva no filtro, atualiza body + navegação
Barra ui().bgDark()List (window(), qs_produto())O input visual com glyphicon de lupa

O fluxo completo: usuário digita → onkeyup dispara QuickSearch via AJAX → texto vai pro filtro na sessão → lView() chama daoListdaoWhere monta o WHERE com qsWhere → SQL retorna só os produtos que casam → body da lista atualiza.

Peça 1 — Coluna qs_produto no banco

Por que uma coluna dedicada? Por que não buscar direto em nome_produto e obs_produto?

Duas razões:

  • Performance. Buscar por substring em N colunas (nome_produto LIKE '%x%' OR obs_produto LIKE '%x%') escala mal. Com uma coluna pré-normalizada, o WHERE vira um único LIKE simples.
  • Normalização. O usuário digita açúcar ou acucar ou AÇÚCAR — todos têm que achar o mesmo registro. Comparar texto bruto com acentos e caixa não funciona. Pré-normalizar uma vez na escrita evita normalizar a cada leitura.

O conteúdo da coluna é a concatenação dos campos pesquisáveis, tudo em lowercase e sem acento:

nome_produtoobs_produtoqs_produto
Açúcar Refinado 1kgcristal finoacucar refinado 1kg cristal fino
Café Torrado 500gtorra médiacafe torrado 500g torra media
Peça 2 — Campo qs_produto no Bean
private String qs_produto;

public String getQs_produto() { return qs_produto; }
public void setQs_produto(String qs_produto) { this.qs_produto = qs_produto; }

public static String QS_PRODUTO = "qs_produto";

Padrão idêntico aos outros campos do Bean: declaração privada, getter, setter, e a constante pública com o nome da coluna no banco. Sem nada especial.

Por que NÃO usar @DBInfo(transient=true)? Justamente porque queremos que o framework persista esse campo. O qs_produto tem que ir pro INSERT e pro UPDATE automaticamente. Deixar como campo normal é o que faz isso acontecer.

A constante QS_PRODUTO é usada em três lugares: no DAO (SQL.column(QS_PRODUTO)), na List (input HTML) e na inner class QuickSearch (leitura do input). Constante centraliza o nome — se mudar a coluna no banco, muda em um lugar só.

Peça 3 — Normalização no DAO (qs_produto + daoWhere)

O DAO ganha duas responsabilidades: preencher o qs_produto na escrita e filtrar por ele na leitura.

Preenchimento — método qs_produto(bean)

public void qs_produto(DepartamentoProdutoBean bean) throws Exception {
    bean.setQs_produto(JasapFunctions.searchString(
            bean.getNome_produto() + " " +
            bean.getObs_produto()
    ));
}

JasapFunctions.searchString() faz o trabalho pesado: recebe a string concatenada, devolve em lowercase e sem acento. É o equivalente Java do lower(unaccent(...)) do PostgreSQL.

Esse método é chamado no daoInsert e daoUpdate, antes do execute. Resultado: toda escrita atualiza o qs_produto automaticamente, sem o programador ter que lembrar.

Filtro — método daoWhere

public void daoWhere(Object objWhere) throws Exception {
    String where = "";
    if (objWhere != null) {
        DepartamentoProdutoWBean filtro = (DepartamentoProdutoWBean) objWhere;

        if (filtro.getQs_produto() != null && !JasapFunctions.equals(filtro.getQs_produto(), "")) {
            String[] cols = { SQL.column(DepartamentoProdutoBean.QS_PRODUTO) };
            where = where + qsWhere(filtro.getQs_produto(), cols);
        }

        filtro.setWhere(where);
    }
}

Decompondo:

  • filtro.getQs_produto() — texto que o usuário digitou (já guardado no WBean pela inner class QuickSearch).
  • qsWhere(texto, cols) — método herdado do JasapDAO. Quebra o texto em palavras, normaliza cada uma e gera o SQL (qs_produto LIKE '%palavra1%' AND qs_produto LIKE '%palavra2%' ...). Suporta busca multi-palavra.
  • filtro.setWhere(where) — grava o WHERE final no WBean. O placeholder <where> no SQL do daoList é substituído por esse texto na hora da execução.

Mudança no daoList

sql.append(" where 1=1 <where> <orderby>");
// ...
daoWhere(list.getFiltro());
select().executeList(query, list);

Duas adições no daoList: o placeholder <where> no SQL (junto com 1=1 que vira o "ponto âncora" pra concatenação dos AND), e a chamada daoWhere(list.getFiltro()) antes do executeList pra montar o WHERE.

Peça 4 — Inner class QuickSearch
public static class QuickSearch extends DepartamentoProdutoList {
    @Override
    public Effect execute() throws Exception {
        getFiltro().setQs_produto(getInput().getString(DepartamentoProdutoBean.QS_PRODUTO));
        update(lView().getDIV_BODY(), lView().getBody());
        update(lView().getDIV_NAVIGATE(), lView().getNavForm());
        eval(Js.setFocusTo(DepartamentoProdutoBean.QS_PRODUTO));
        return new Response();
    }
}

A action chamada a cada tecla digitada na barra de busca. 4 linhas de execução:

  • getFiltro().setQs_produto(getInput().getString(...)) — lê o texto digitado (vem do onkeyup da barra) e grava no WBean. O WBean fica na sessão, então o filtro persiste entre cliques na lista, ordenação, abertura de form etc.
  • update(getDIV_BODY(), getBody()) — re-renderiza só as linhas da tabela. Como lView() chama daoList, e daoList chama daoWhere com o filtro recém-atualizado, o SQL volta com a lista filtrada.
  • update(getDIV_NAVIGATE(), getNavForm()) — atualiza a área de paginação. Sem isso, contagem total fica errada quando paginação for adicionada depois.
  • eval(Js.setFocusTo(QS_PRODUTO)) — devolve o foco pro campo de busca. Sem isso, o usuário perderia o foco a cada tecla e a barra ficaria inutilizável.
Peça 5 — Barra de busca com ui().bgDark()
protected Text qs_produto = null;
public String qs_produto() throws Exception {
    qs_produto = ui().text(DepartamentoProdutoBean.QS_PRODUTO)
            .setLabel("Consulta")
            .setStyle("width:400;height:30px;font-size:14px")
            .setMaxlength(300)
            .setValue(getFiltro().getQs_produto())
            .setOnkeyup(Js.pressEnter(
                    link(QuickSearch.class)
                            .putScript(DepartamentoProdutoBean.QS_PRODUTO, Js.SELF_VALUE)
                            .ajax()));

    Table aux = new Table(getManager(), "100%", "45")
            .setStyle(ui().bgDark())
            .rowC()
            .setAlign(Table.ALIGN_CENTER)
            .setVerticalAlign(Table.ALIGN_MIDDLE)
            .setContent(qs_produto.toHtml() + "<span class=\"glyphicon glyphicon-search xt-ico-white\"></span>")
            .table();

    return aux.toHtml();
}

O método monta o input + lupa dentro de uma barra horizontal escura. Decompondo:

  • ui().text(QS_PRODUTO) — cria um input HTML com o nome do parâmetro igual à constante QS_PRODUTO. Esse nome é o que getInput().getString(QS_PRODUTO) lê do outro lado.
  • .setValue(getFiltro().getQs_produto()) — preenche com o texto que estava na sessão. Sem isso, ao fechar e reabrir um form, a barra apareceria vazia mesmo com filtro ativo.
  • .setOnkeyup(Js.pressEnter(...)) — dispara a action QuickSearch ao pressionar Enter. Js.SELF_VALUE envia o conteúdo atual do próprio input.
  • ui().bgDark() — CSS que dá o fundo escuro (cinza chumbo) na barra, padrão visual de barras de filtro no XT.
  • glyphicon glyphicon-search — ícone de lupa do Bootstrap, branco (xt-ico-white), à direita do input.

A barra é incluída no window() via w.rowC("1%", null, qs_produto()) — uma linha horizontal entre a lista e o rodapé.

regAction — registrar no Manager
regAction(DepartamentoProdutoList.QuickSearch.class);

Uma linha no DepartamentoManager.config(). Sem isso, o framework não conhece a inner class QuickSearch e o onkeyup da barra retorna "setor não encontrado".

A linha fica logo depois do regAction(Sort.class), antes do DeleteFromList. Organização lógica: List → Sort → QuickSearch → DeleteFromList.

Limitação — por que vl_produto e qtd_produto ficam de fora

Repare que o método qs_produto(bean) só concatena nome e observação:

bean.setQs_produto(JasapFunctions.searchString(
        bean.getNome_produto() + " " +
        bean.getObs_produto()
));

Valor e quantidade ficam de fora de propósito. Buscar número como texto não funciona bem em pt-BR:

Você digitaColuna temEncontra?
5.25.2sim
5,25.2não
10001000 (mas em vários produtos)casa demais — falso positivo

Em Java, Double.toString() sempre usa ponto como separador decimal. Mas o usuário brasileiro digita vírgula. Se incluíssemos valor no qs_produto, a busca por preço quebraria pra todo mundo.

O Jasap resolve isso com outra feature: filtros de range (vl_ini / vl_fim) — campos separados pra "de R$ X até R$ Y". Esse é assunto pro próximo episódio "Filtros avançados". A barra de busca rápida é pra texto livre — nome, observação, descrição.

O que NÃO faz parte do QuickSearch

O QuickSearch isolado faz uma coisa só: filtrar a lista por texto digitado. Algumas features parecem fazer parte mas são separadas:

FeatureFaz parte?Porquê
Trocar setOnCloseURLurl(Sort.class)NãoOtimização: faz a barra preservar o filtro ao fechar form. Mas é uma linha trocada na List, episódio próprio se quiser destacar.
Filtro de range numérico (vl_ini/vl_fim)NãoFiltros avançados — feature separada (próximo episódio)
Reset de página (resetPage)NãoSó faz sentido com paginação ativa — episódio de paginação
Tabs de statusNãoFiltro por status — feature separada

O QuickSearch que implementamos aqui é a base: 5 peças coordenadas, busca fuzzy funcionando. As features acima estendem o filtro de outras formas, mas não dependem do QuickSearch e o QuickSearch não depende delas.

Resumo — o que mudou
Arquivo / RecursoTipoEdição
SQLnovoColuna qs_produto text + extensão unaccent + UPDATE de backfill
DepartamentoProdutoBeaneditarCampo + getter/setter + constante QS_PRODUTO
DepartamentoProdutoDAOeditar2 imports, <where> no SQL, chamadas qs_produto(bean) no insert/update, métodos qs_produto e daoWhere
DepartamentoProdutoListeditar1 import (Text), linha no window(), método qs_produto(), inner class QuickSearch
DepartamentoManagereditar1 regAction(QuickSearch.class)

4 arquivos editados, 1 alteração no banco, nenhum arquivo Java novo. Resultado: barra de busca rápida funcionando com normalização (case-insensitive, sem acento), suporte a múltiplas palavras, e filtro persistente na sessão.