Data readers
Paginação, ordenação e filtros são os mesmos problemas, venham os dados do banco, de um array ou de uma API. O pacote yiisoft/data define uma interface comum para "algo de onde se leem dados", e os paginadores, o Sort e os widgets de dados trabalham em cima dela, sem saber de onde os itens vêm. Este capítulo mostra essas interfaces, os filtros e os dois leitores que você vai usar no dia a dia.
As interfaces
Um data reader é um objeto imutável que sabe ler itens. O que ele sabe fazer além disso é dito pelas interfaces que implementa, todas em Yiisoft\Data\Reader:
ReadableDataInterface:read()devolve os itens ereadOne()devolve o primeiro.CountableDataInterface:count()devolve o total.LimitableDataInterfaceeOffsetableDataInterface:withLimit()ewithOffset()cortam o resultado. É o que oOffsetPaginatorusa.SortableDataInterface:withSort()recebe umSort.FilterableDataInterface:withFilter()recebe um filtro.
A DataReaderInterface junta todas elas. Cada método with*() devolve um leitor novo, então você pode partir de uma base e derivar variações sem uma afetar a outra.
Dois leitores vêm prontos:
IterableDataReader, doyiisoft/data, lê de um array ou de qualqueriterable. Filtra, ordena e corta em PHP.QueryDataReader, doyiisoft/data-db, lê de uma consulta do query builder. Transforma filtro, ordenação e limites em SQL.
Se os dados vêm de outro lugar (uma API externa, um índice de busca), você pode escrever o seu leitor implementando as interfaces que fizerem sentido.
Filtros
Filtros são objetos do namespace Yiisoft\Data\Reader\Filter, e o mesmo filtro funciona em qualquer leitor que implemente FilterableDataInterface:
- Comparação:
Equals,GreaterThan,GreaterThanOrEqual,LessThan,LessThanOrEqual,Between,In,EqualsNull. - Texto:
Like, com umLikeMode(Contains, o padrão,StartsWithouEndsWith) e um parâmetro para diferenciar maiúsculas. - Combinação:
AndX,OrXeNot. - Especiais:
All, que não filtra nada, eNone, que não deixa passar nada.
use Yiisoft\Data\Reader\Filter\AndX;
use Yiisoft\Data\Reader\Filter\Between;
use Yiisoft\Data\Reader\Filter\Equals;
use Yiisoft\Data\Reader\Filter\OrX;
$filtro = new OrX(
new Equals('autorId', 1),
new Between('ano', 1930, 1940),
);Os nomes dos campos (autorId, ano) são os do leitor, e não necessariamente os do banco. A seguir você vai ver como o QueryDataReader liga um ao outro.
Lendo de uma consulta
O Catalogo, em src/Dados/Catalogo.php, monta o leitor de livros que este capítulo e o próximo usam:
<?php
declare(strict_types=1);
namespace App\Dados;
use Yiisoft\Data\Db\QueryDataReader;
use Yiisoft\Data\Reader\Sort;
use Yiisoft\Db\Connection\ConnectionInterface;
final readonly class Catalogo
{
public function __construct(
private ConnectionInterface $db,
) {}
public function livros(): QueryDataReader
{
$query = $this->db
->select([
'id' => 'l.id',
'titulo' => 'l.titulo',
'autor' => 'a.nome',
'ano' => 'l.ano',
'preco' => 'l.preco',
])
->from(['l' => 'livro'])
->innerJoin(['a' => 'autor'], 'a.id = l.autor_id');
return new QueryDataReader(
query: $query,
sort: Sort::only(['titulo', 'autor', 'ano', 'preco']),
fieldMapper: [
'id' => 'l.id',
'titulo' => 'l.titulo',
'autor' => 'a.nome',
'autorId' => 'l.autor_id',
'ano' => 'l.ano',
'preco' => 'l.preco',
],
);
}
/**
* @return array<string, string>
*/
public function autores(): array
{
return $this->db
->select(['nome', 'id'])
->from('autor')
->orderBy(['nome' => SORT_ASC])
->indexBy('id')
->column();
}
}O construtor do QueryDataReader recebe a consulta e, opcionalmente, o Sort inicial, os limites, os filtros e o fieldMapper.
O fieldMapper traduz os nomes de campo do leitor para colunas da consulta, tanto nos filtros quanto na ordenação. Ele resolve três problemas:
- Com o
JOIN,idsozinho é ambíguo (livro.idouautor.id?). O mapa diz que él.id. autorIdnão está entre as colunas selecionadas, mas pode ser filtrado: viral.autor_id.- A tela usa nomes que não precisam ser os do banco. Se a coluna mudar de nome, só o mapa muda.
Com o filtro de antes, mais withLimit(3) e withOffset(1), o SQL gerado pelo leitor foi:
SELECT "l"."id" AS "id", "l"."titulo" AS "titulo", "a"."nome" AS "autor", "l"."ano" AS "ano", "l"."preco" AS "preco"
FROM "livro" "l" INNER JOIN "autor" "a" ON a.id = l.autor_id
WHERE ("l"."autor_id" = 1) OR ("l"."ano" BETWEEN 1930 AND 1940)
ORDER BY "l"."titulo", "a"."nome", "l"."ano", "l"."preco" LIMIT 3 OFFSET 1O ORDER BY veio do Sort configurado no construtor, com a ordem padrão de todos os campos. O método getPreparedQuery() devolve essa consulta final, útil para conferir o SQL ou para reaproveitar a consulta em outro lugar.
Algumas observações sobre o QueryDataReader:
read()executa a consulta uma vez e guarda o resultado no objeto. Ler de novo o mesmo leitor não vai ao banco.count()faz umSELECT COUNT(*)com os mesmos filtros, sem limite nem ordenação.- Para tabelas grandes,
withBatchSize(100)faz oread()buscar os itens em lotes de 100, em vez de carregar tudo na memória.
Filtros vindos da URL
A página /dados/filtros monta os filtros a partir de um formulário de busca. Crie src/Web/Dados/Filtros/Action.php:
<?php
declare(strict_types=1);
namespace App\Web\Dados\Filtros;
use App\Dados\Catalogo;
use Psr\Http\Message\ResponseInterface;
use Yiisoft\Data\Reader\Filter\AndX;
use Yiisoft\Data\Reader\Filter\Equals;
use Yiisoft\Data\Reader\Filter\LessThanOrEqual;
use Yiisoft\Data\Reader\Filter\Like;
use Yiisoft\Data\Reader\Iterable\IterableDataReader;
use Yiisoft\Data\Reader\Sort;
use Yiisoft\Input\Http\Attribute\Parameter\Query;
use Yiisoft\Yii\View\Renderer\WebViewRenderer;
final readonly class Action
{
private const array NASCIMENTOS = [
['nome' => 'Machado de Assis', 'cidade' => 'Rio de Janeiro', 'nascimento' => 1839],
['nome' => 'Graciliano Ramos', 'cidade' => 'Quebrangulo', 'nascimento' => 1892],
['nome' => 'Cecília Meireles', 'cidade' => 'Rio de Janeiro', 'nascimento' => 1901],
['nome' => 'Jorge Amado', 'cidade' => 'Itabuna', 'nascimento' => 1912],
['nome' => 'Clarice Lispector', 'cidade' => 'Chechelnyk', 'nascimento' => 1920],
];
public function __construct(
private WebViewRenderer $viewRenderer,
private Catalogo $catalogo,
) {}
public function __invoke(
#[Query('busca')] string $busca = '',
#[Query('autor')] string $autor = '',
#[Query('preco_max')] string $precoMaximo = '',
): ResponseInterface {
$filtros = [];
if ($busca !== '') {
$filtros[] = new Like('titulo', $busca);
}
if (ctype_digit($autor)) {
$filtros[] = new Equals('autorId', (int) $autor);
}
if (is_numeric($precoMaximo)) {
$filtros[] = new LessThanOrEqual('preco', (float) $precoMaximo);
}
$livros = $this->catalogo->livros()->withFilter(new AndX(...$filtros));
$nascimentos = (new IterableDataReader(self::NASCIMENTOS))
->withSort(Sort::only(['nascimento', 'nome'])->withOrderString('-nascimento'));
if ($busca !== '') {
$nascimentos = $nascimentos->withFilter(new Like('nome', $busca));
}
return $this->viewRenderer->render(__DIR__ . '/template', [
'busca' => $busca,
'autor' => $autor,
'precoMaximo' => $precoMaximo,
'autores' => $this->catalogo->autores(),
'livros' => $livros->read(),
'total' => $livros->count(),
'nascimentos' => $nascimentos->read(),
]);
}
}Cada campo do formulário só vira filtro se tiver um valor aproveitável. Os parâmetros chegam como string e são validados antes: ctype_digit() para o id do autor, is_numeric() para o preço. Um campo vazio ou com lixo simplesmente não filtra. Um AndX sem nenhum filtro dentro não acrescenta condição: com o formulário vazio, aparecem todos os livros.
Para um formulário com mais campos e regras, vale usar um FormModel com validação, como no capítulo Validação, e montar os filtros a partir dele.
Lendo de um array
Na mesma action, a lista de autores com local e ano de nascimento não está no banco: é a constante NASCIMENTOS. O IterableDataReader oferece para ela a mesma interface do leitor do banco. O Sort ordena do nascimento mais recente para o mais antigo, e o mesmo texto da busca filtra pelo nome com Like.
O IterableDataReader faz tudo em PHP: percorre os itens, aplica o filtro, ordena e corta. Isso serve bem para listas pequenas ou que já estão na memória. Para muitos itens vindos de um banco, filtrar no SQL com o QueryDataReader é sempre melhor que carregar tudo e filtrar depois. Por padrão, o Like do IterableDataReader não diferencia maiúsculas de minúsculas, inclusive em letras acentuadas, porque usa mb_stripos(). Mas "a" e "á" continuam sendo letras diferentes.
Por padrão, cada item é um array e o campo é uma chave. Para objetos ou campos aninhados (autor.nome), passe outro leitor de valores no segundo argumento do construtor, como o PathValueReader.
A view
A view, src/Web/Dados/Filtros/template.php, tem o formulário e as duas listas:
<?php
declare(strict_types=1);
use Yiisoft\Html\Html;
/**
* @var Yiisoft\View\WebView $this
* @var string $busca
* @var string $autor
* @var string $precoMaximo
* @var array<string, string> $autores
* @var iterable<array> $livros
* @var int $total
* @var iterable<array> $nascimentos
*/
$this->setTitle('Busca no catálogo');
?>
<h1>Busca no catálogo</h1>
<form method="get">
<?= Html::textInput('busca', $busca, ['placeholder' => 'Texto']) ?>
<?= Html::select('autor')->optionsData($autores)->prompt('Todos os autores')->value($autor) ?>
<?= Html::input('number', 'preco_max', $precoMaximo, ['step' => '0.01', 'placeholder' => 'Preço máximo']) ?>
<?= Html::submitButton('Buscar') ?>
</form>
<h2><?= $total ?> livro(s)</h2>
<ul>
<?php foreach ($livros as $livro): ?>
<li><?= Html::encode($livro['titulo']) ?>, de <?= Html::encode($livro['autor']) ?> (R$ <?= Html::encode($livro['preco']) ?>)</li>
<?php endforeach ?>
</ul>
<h2>Autores</h2>
<ul>
<?php foreach ($nascimentos as $pessoa): ?>
<li><?= Html::encode($pessoa['nome']) ?>, nascido(a) em <?= Html::encode($pessoa['cidade']) ?> em <?= $pessoa['nascimento'] ?></li>
<?php endforeach ?>
</ul>O select de autores usa o array [id => nome] que Catalogo::autores() monta com indexBy('id') e column().
A rota:
Route::get('/dados/filtros')
->action(Web\Dados\Filtros\Action::class)
->name('dados/filtros'),Testando:
/dados/filtrosmostra "12 livro(s)" e os cinco autores, de Clarice Lispector (1920) a Machado de Assis (1839)./dados/filtros?autor=3&preco_max=70mostra 2 livros de Jorge Amado: "Capitães da Areia" e "Gabriela, Cravo e Canela". "Dona Flor e Seus Dois Maridos", de 74,90, fica de fora./dados/filtros?busca=mamostra "Dona Flor e Seus Dois Maridos" e "Memórias Póstumas de Brás Cubas", e na lista de autores, Jorge Amado e Machado de Assis./dados/filtros?busca=&autor=&preco_max=, que é o que o formulário envia vazio, mostra tudo.
Comparando com o Yii2
- O papel do
ActiveDataProvidere doArrayDataProviderdo Yii2 é feito peloQueryDataReadere peloIterableDataReader. A paginação e a ordenação não ficam dentro do leitor comopaginationesort; são objetos separados que se aplicam a ele. - Os filtros do Yii2 eram condições do query builder, presas ao banco. Aqui, os filtros são objetos independentes do armazenamento, e cada leitor traduz para o seu formato.