Skip to content

Lookup de banco de dados em filtros

Este tutorial mostra como criar um relatório com parâmetros cujas opções são buscadas diretamente de um banco de dados PostgreSQL. O usuário escolhe uma categoria e depois um produto dentro dela — os dois filtros são encadeados.

Funcionalidades cobertas:

  • LookupConfig.db com query parametrizada
  • Paginação com pageSize e placeholders {_page_size} / {_offset}
  • Busca incremental com {_search}
  • Cascata entre parâmetros com dependsOn
  • DbLookupResolver do pacote sulfite_datasources

Estrutura do banco

sql
CREATE TABLE categories (
  id   SERIAL PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE products (
  id          SERIAL PRIMARY KEY,
  category_id INT  REFERENCES categories(id),
  name        TEXT NOT NULL,
  price       NUMERIC(10, 2) NOT NULL
);

Definição do relatório

json
{
  "reportId": "products_by_category",
  "reportName": "Produtos por Categoria",
  "parameters": [
    {
      "id": "categoryId",
      "label": "Categoria",
      "type": "select",
      "required": true,
      "lookup": {
        "source": "db",
        "connectionRef": "main_db",
        "query": "SELECT id, name FROM categories WHERE {_search} = '' OR name ILIKE '%' || {_search} || '%' ORDER BY name LIMIT {_page_size} OFFSET {_offset}",
        "valueField": "id",
        "labelField": "name",
        "pageSize": 20,
        "searchOnOpen": true,
        "cacheSeconds": 300
      }
    },
    {
      "id": "productId",
      "label": "Produto",
      "type": "select",
      "required": false,
      "dependsOn": ["categoryId"],
      "lookup": {
        "source": "db",
        "connectionRef": "main_db",
        "query": "SELECT id, name FROM products WHERE category_id = {categoryId} AND ({_search} = '' OR name ILIKE '%' || {_search} || '%') ORDER BY name LIMIT {_page_size} OFFSET {_offset}",
        "valueField": "id",
        "labelField": "name",
        "pageSize": 25,
        "searchOnOpen": true,
        "refreshOnDependencyChange": true,
        "cacheSeconds": 60
      }
    },
    {
      "id": "startDate",
      "label": "Data Início",
      "type": "date",
      "defaultValue": "2024-01-01",
      "required": true
    },
    {
      "id": "endDate",
      "label": "Data Fim",
      "type": "date",
      "defaultValue": "2024-12-31",
      "required": true,
      "validation": {
        "rule": "dateAfter",
        "compareWith": "startDate",
        "errorMessage": "Data Fim deve ser posterior à Data Início"
      }
    }
  ],
  "dataSources": [
    {
      "id": "sales",
      "type": "list",
      "source": "external",
      "url": "https://api.example.com/sales?categoryId={categoryId}&productId={productId}&from={startDate}&to={endDate}",
      "schema": {
        "product": "string",
        "quantity": "integer",
        "unitPrice": "number",
        "total": "number",
        "saleDate": "string"
      }
    }
  ],
  "bands": [
    {
      "type": "header",
      "id": "header",
      "height": 60,
      "elements": [
        {
          "type": "text",
          "id": "title",
          "x": 0, "y": 0,
          "width": 780, "height": 30,
          "content": "Relatório de Vendas por Produto",
          "fontSize": 16,
          "bold": true,
          "align": "center"
        },
        {
          "type": "text",
          "id": "period",
          "x": 0, "y": 35,
          "width": 780, "height": 20,
          "content": "Período: {startDate} a {endDate}",
          "fontSize": 10,
          "align": "center",
          "color": "#555555"
        }
      ]
    },
    {
      "type": "detail",
      "id": "detail",
      "dataSourceId": "sales",
      "height": 24,
      "elements": [
        {
          "type": "field",
          "id": "f_product",
          "x": 0, "y": 4,
          "width": 280, "height": 16,
          "binding": "product",
          "fontSize": 10
        },
        {
          "type": "field",
          "id": "f_date",
          "x": 290, "y": 4,
          "width": 100, "height": 16,
          "binding": "saleDate",
          "format": "date:dd/MM/yyyy",
          "fontSize": 10
        },
        {
          "type": "field",
          "id": "f_qty",
          "x": 400, "y": 4,
          "width": 80, "height": 16,
          "binding": "quantity",
          "format": "integer",
          "align": "right",
          "fontSize": 10
        },
        {
          "type": "field",
          "id": "f_unit",
          "x": 490, "y": 4,
          "width": 130, "height": 16,
          "binding": "unitPrice",
          "format": "currency:BRL",
          "align": "right",
          "fontSize": 10
        },
        {
          "type": "field",
          "id": "f_total",
          "x": 630, "y": 4,
          "width": 150, "height": 16,
          "binding": "total",
          "format": "currency:BRL",
          "align": "right",
          "fontSize": 10,
          "bold": true
        }
      ]
    },
    {
      "type": "summary",
      "id": "summary",
      "height": 36,
      "elements": [
        {
          "type": "text",
          "id": "lbl_total",
          "x": 490, "y": 8,
          "width": 130, "height": 20,
          "content": "Total geral:",
          "bold": true,
          "align": "right"
        },
        {
          "type": "aggregate",
          "id": "grand_total",
          "x": 630, "y": 8,
          "width": 150, "height": 20,
          "verb": "SUM",
          "targetKey": "total",
          "dataSourceId": "sales",
          "format": "currency:BRL",
          "align": "right",
          "bold": true
        }
      ]
    }
  ]
}

Configurando o DbLookupResolver

Registre a conexão e passe o resolver ao abrir o diálogo de filtros:

dart
import 'package:sulfite_core/sulfite_core.dart';
import 'package:sulfite_datasources/sulfite_datasources.dart';
import 'package:sulfite_studio/sulfite_studio.dart';

// 1. Registrar a conexão (uma vez por sessão)
final registry = ConnectionRegistry();
registry.register(DatabaseConnectionEntry(
  id: 'main_db',
  host: 'db.example.com',
  port: 5432,
  database: 'myapp',
  username: 'reports_user',
  password: 'secret',
  ssl: true,
));

// 2. Criar o resolver
final dbResolver = DbLookupResolver(registry: registry);

// 3. Exibir o diálogo de filtros
Future<void> printReport(BuildContext context, ReportDefinition report) async {
  final params = await SulfiteFilterScreen.show(
    context,
    parameters: report.parameters,
    lookupResolver: dbResolver,
  );

  if (params == null) return; // usuário cancelou

  // 4. Gerar o relatório com os parâmetros selecionados
  final engine = SulfiteEngine();
  final pdfBytes = await engine.generate(
    report: report,
    data: await fetchData(params),  // buscar dados conforme parâmetros
    params: params,
  );

  // Exibir ou salvar pdfBytes...
}

Como os placeholders são resolvidos

O DbLookupResolver injeta automaticamente os seguintes parâmetros em cada execução de query:

PlaceholderPreenchido com
{_search}Texto digitado na caixa de busca (string vazia se não houver)
{_page}Número da página atual (começa em 1)
{_page_size}Valor de pageSize da config (ou 0)
{_offset}(page - 1) * pageSize

Os parâmetros do relatório (ex.: {categoryId}) também são substituídos, permitindo cascata.

Nota de segurança: o resolver usa Sql.named do pacote postgres internamente — os valores são passados como parâmetros vinculados, não concatenados na string SQL.

Paginação no diálogo de seleção

Quando pageSize > 0, o diálogo carrega os itens em páginas à medida que o usuário rola a lista. Configure a query para suportar LIMIT / OFFSET:

sql
SELECT id, name
FROM products
WHERE category_id = {categoryId}
  AND ({_search} = '' OR name ILIKE '%' || {_search} || '%')
ORDER BY name
LIMIT {_page_size} OFFSET {_offset}

Quando pageSize = 0, toda a lista é buscada de uma vez (adequado para tabelas pequenas).

Cache

O resolver mantém um cache em memória por cacheSeconds segundos. A chave de cache inclui connectionRef, a query template e todos os parâmetros — duas chamadas com parâmetros diferentes não colidem.

Para invalidar o cache manualmente:

dart
dbResolver.dispose(); // limpa os caches e encerra conexões abertas

Usando múltiplas conexões

O ConnectionRegistry suporta múltiplas entradas — útil quando lookups e dados vêm de bancos diferentes:

dart
registry.register(DatabaseConnectionEntry(
  id: 'catalog_db',
  host: 'catalog.example.com',
  database: 'catalog',
  username: 'user',
  password: 'pass',
));

registry.register(DatabaseConnectionEntry(
  id: 'sales_db',
  host: 'sales.example.com',
  database: 'sales',
  username: 'user',
  password: 'pass',
));

Cada LookupConfig.db referencia a conexão pelo connectionRef correspondente.

Próximos passos

Sulfite do 🇧🇷 para o mundo © 2026 Rafael S. Pinheiro