⌘K

Tabulka

Zdroje dat

Odkud tabulka bere řádky, když nejsou v databázi — kontrakt zdroje a co od něj tabulka pořád očekává.

Na této stránce

Tabulka čte řádky přes zdroj dat. Ve výchozím stavu ten zdroj obaluje Eloquent builder a nikdy ho neuvidíte — ->model() a ->query() fungují přesně jako dosud. Vlastní zdroj deklarujete, když řádky nejsou v databázi: read model, seznam DTO, odpověď API.

Mechanismus

Čtení tabulky jsou tři otázky a zdroj odpovídá na všechny: které řádky odpovídají (count), tahle jejich stránka (paginate) a řádek za tímhle klíčem (resolveRecord). Zdroj dostane QueryPlan — hledání, filtry, řazení a joiny, které tabulka vyřešila ze stavu — a PagingRequest pro výřez.

Plán je deklarativní. Filtr je sloupec, operátor a hodnota, nikdy closure, a právě to dělá zdroj mimo databázi vůbec možným: umí plán přečíst a odpovědět na něj metodami Collection místo SQL.

Na co zdroj odpovědět neumí, to deklaruje, že neumí — a engine pak vyhodí výjimku místo toho, aby vrátil řádky, které tiše ignorovaly půlku dotazu:

public function capabilities(): CapabilitySet
{
return new CapabilitySet(
Capability::Filterable,
Capability::Sortable,
Capability::Paginable,
); // žádné SqlExpression, žádné Joinable — dotaz na ně vyhodí výjimku
}

To je celá bezpečnostní vlastnost. Tabulka nad API, které neumí řadit, to musí říct — tabulka, která řadí podle ničeho a vypadá seřazeně, je horší než ta, která zahlásí chybu.

Tabulka nad řádky v paměti

use NyonCode\WireTable\Data\CollectionDataSource;
 
public function table(Table $table): Table
{
return $table
->dataSource(new CollectionDataSource([
['id' => 1, 'name' => 'Ada', 'score' => 90],
['id' => 2, 'name' => 'Grace', 'score' => 70],
]))
->columns([
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('score')->sortable(),
]);
}

Žádné ->model(), žádné ->query(). Hledání, filtry, řazení i stránkování fungují; zdroj na ně odpoví nad polem — text řadí podle jazyka aplikace (přes intl Collator, kde je nainstalovaný), takže Černý je před Dvořákem, ne za Veselým. Totéž platí o částech postavených nad nimi: souhrny v patičce sčítají vše, co filtry propustí, ne jen stránku, a „vybrat vše odpovídající" předá hromadné akci každý odpovídající řádek.

Každý řádek dorazí do sloupců, akcí i hromadných akcí jako CollectionRow — model jen pro čtení s klíčem, jaký mu dal zdroj, takže $record->name a $record->getKey() se čtou jako u Eloquentu. save() nebo delete() na něm vyhodí výjimku: nemá tabulku, do které by zapsal.

Co tabulka nad kolekcí neumí

CollectionDataSource deklaruje čtyři schopnosti a zbytek odmítá. Každé odmítnutí vyhodí UnsupportedQueryAspectException se jménem aspektu:

Když chcete Odkud to přijde Výsledek
Raw SQL výraz Column::sortUsing() se SQL, ->sqlExpression() vyhodí [sql_expression]
Cestu přes relaci TextColumn::make('company.name') vyhodí [joinable]
Agregace přes subquery rollupy ->sums(), ->counts() vyhodí [aggregateable]
Cursor stránkování ->paginationMode('cursor') vyhodí [cursor paging]
Detekci změny pro polling ->poll() vrátí null; polling místo toho porovná řádky

Sama tabulka odmítá to, co umí jen Eloquent builder. Deklarovat to nic nestojí; ve chvíli, kdy se to použije, to CustomDataSourceException pojmenuje, místo aby spadla na chybějícím modelu:

Když chcete Odkud to přijde Výsledek
Filtr, který se aplikuje jako dotaz DateFilter, Filter::query(fn ($query) => …) vyhodí, jakmile dostane hodnotu
Hledání nebo řazení zapsané jako callback ->searchable(true, fn ($query, $term) => …), ->sortable(true, fn …) vyhodí při hledání či řazení
Výběr jako builder selectedRecordsQuery() vyhodí; getSelectedRecords() místo toho čte řádky
Zápis řádku zpět $record->save(), $record->delete() vyhodí

Nic z toho nejsou omezení, která by se dala tiše obejít. Tabulka nad seznamem v paměti je omezená tabulka a to omezení je vidět hned při první žádosti o něco mimo něj.

Vlastní zdroj

Implementujte NyonCode\WireCore\Core\Data\DataSource. Každá metoda dostane plán, takže zdroj rozhoduje, kolik z něj splní:

use NyonCode\WireCore\Core\Data\DataSource;
 
final class ReportingApiSource implements DataSource
{
public function paginate(QueryPlan $plan, PagingRequest $paging): LengthAwarePaginator|Paginator|CursorPaginator;
public function get(QueryPlan $plan): Collection;
public function chunk(QueryPlan $plan, int $size, callable $callback): void;
public function count(QueryPlan $plan): int;
public function resolveRecord(int|string $key): ?RecordContract;
public function resolveRecords(array $keys): Collection;
public function capabilities(): CapabilitySet;
public function changeToken(QueryPlan $plan): ?string;
}

Dvě z nich stojí za vysvětlení, protože jejich tvar není samozřejmý:

chunk() existuje odděleně od get(), aby export sta tisíc řádků zůstal ohraničený v paměti. get() materializuje; chunk() streamuje a zastaví se dřív, když callback vrátí false.

changeToken() smí vrátit null a null je skutečná odpověď, ne selhání: znamená, že zdroj nemá levný způsob, jak zjistit, že se data pohnula — polling tedy porovná řádky sám místo zkratky přes token.

Záznamy

Zdroj vrací řádky jako RecordContract — getKey(), get('dot.path'), toArray(), unwrap(). Ve stacku jsou dvě implementace: EloquentRecord obaluje model, ArrayRecord pole.

Váš vlastní kód dál dostává modely. Framework rozbaluje na hranici, takže closure akce si drží svoji obvyklou signaturu:

Action::make('archive')
->action(fn (Model $record) => $record->archive());

Nad CollectionDataSource není co rozbalit, takže tabulka předá closure CollectionRow sestavený z pole. Signatura platí; co closure s řádkem udělá, je na ní — $record->archive() na něm neexistuje a $record->save() vyhodí výjimku místo předstíraného zápisu.

Co zůstává jen pro Eloquent

Dvě cesty si záměrně nechávají builder místo kontraktu a obě jsou případy, které kontrakt vyjádřit neumí:

  • Rollupy nad výběrem replikují agregační subquery na klíčovaný dotaz.
  • Zápisy fill handlu berou pesimistický zámek řádku (SELECT … FOR UPDATE).

Vlastní zdroj nedostane ani jedno.

Viz také

  • Exporty — streamují přes chunk() zdroje
  • Souhrny — agregace, které ne-SQL zdroj odmítne
  • Výběr — hromadné akce a resolveRecords()