Elyra
Elyra The coding agent e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Elyra
Pivot (cross-tab)

Pivot (cross-tab)

Drag dimensions onto Rows and Columns, pick Values, and get a server-side cross-tab: row and column subtotals, margins and a grand total, over as many rows as the database can group.

Available in all three clients:

Client Component
Livewire Elyra\DataGrid\Livewire\DataPivot
Vue DataPivot from @elyra/datagrid-vue
Svelte DataPivot from @elyra/datagrid-svelte

Nothing new is asked of the server. A pivot is a re-presentation of the protocol's existing group + aggregates, so it works on every driver and inherits the same allow-list.

Livewire

use Elyra\DataGrid\Livewire\DataPivot;

class SalesPivot extends DataPivot
{
    public array $rows = ['region'];
    public array $cols = ['status'];

    protected function definition(): GridDefinition
    {
        return Grid::table('sales')->key('id')
            ->columns(['region', 'status', 'category'])
            ->column('revenue', type: 'number', aggregatable: true);
    }

    /** Fields offerable on either axis. */
    protected function dimensions(): array
    {
        return [
            ['field' => 'region', 'header' => 'Region'],
            ['field' => 'status', 'header' => 'Status'],
        ];
    }

    /** Measures offerable in the Values bucket. */
    protected function measures(): array
    {
        return [['field' => 'revenue', 'fn' => 'sum', 'header' => 'Revenue']];
    }
}

Vue / Svelte

Same GridOptions a DataGrid takes; dimensions come from the declared columns.

<DataPivot :options="options" :rows="['region']" :cols="['status']"
  :values="[{ field: 'revenue', fn: 'sum', as: 'rev' }]" />
<DataPivot {options} rows={["region"]} cols={["status"]}
  values={[{ field: "revenue", fn: "sum", as: "rev" }]} />

Headless, if you want your own rendering:

import { loadPivot } from "@elyra/datagrid-client-core";

const model = await loadPivot(
  { rows: ["region"], cols: ["status"], values: [{ field: "revenue", fn: "sum", as: "rev" }] },
  options.fetch,
);

model.cell("Nord", "paid", "rev");   // a body cell, or null
model.rowTotal("Nord", "rev");       // that row's Total column
model.colTotal("paid", "rev");       // that column's Total row
model.grandTotal("rev");

Subtotals are correct for non-additive measures

This is the part most pivot implementations get wrong. Every subtotal, marginal and grand total is its own aggregate query — nothing is derived by summing the visible cells.

It matters as soon as a measure is not additive. With avg(revenue) over Nord = {10, 20, 5} split by status:

paid pending Total
Nord 15 5 11.67

The subtotal is 35 / 3. It is neither the sum of the cells (20) nor their mean (10). Summing cells would produce a confidently wrong number, so the model exposes no way to do it. The same holds for median, percentile and countDistinct.

What it costs

A pivot is rows + 2 requests — one for the body, one per intermediate row level (for that subtotal line's per-column cells), one for the column axis and its marginals, and one for the grand total. They are independent and issued in parallel.

Requests are not queries: the engine runs one query per grouping level.

Layout Requests Queries
1 row × 1 column 3 4
2 rows × 1 column 4 7

Budget before adding dimensions. GROUPING SETS would fold all of it into one query, but only PostgreSQL and ClickHouse support it among the five drivers, so the portable shape is several queries.

Why the extra requests exist

group: [A, B, C] returns aggregates for the prefixes of that list — A, A×B, A×B×C. For rows [A, B] and columns [C] that already covers the body cells and every line's Total column. But a subtotal line for A needs a value per column, i.e. GROUP BY (A, C) — which is not a prefix of [A, B, C]. Hence one more request per intermediate row level.

Keep the column axis low-cardinality

Each distinct value on the column axis becomes a rendered column. Pivoting on something like customer_id produces a column per customer, and both the DOM and the GROUP BY scale with it.

Group rows are capped per level by GridLimits::maxGroupRows (10 000). When a level hits the cap the response sets groupsTruncated, the model reports truncated, and all three clients show a notice — a partial cross-tab is never presented as complete.

Paging the row axis

The row axis is paged by its outermost dimension, so a pivot is not limited to maxGroupRows row groups. Every descendant of a selected outer group comes with it, which is what keeps the row tree whole — a page is a set of outer groups, not an arbitrary slice of leaf rows.

All three clients page at 50 row groups by default (rowPageSize; 0 turns it off and lets the server's cap bound the axis instead). Headless:

const model = await loadPivot(
  { rows: ["region"], cols: ["status"], values: [...], rowSkip: 0, rowTake: 50 },
  options.fetch,
);
model.rowAxisTotal;   // outer row groups in total, for building a pager

Changing the layout resets to the first page — an offset into a different axis means nothing.

Paging only applies to the body and subtotal queries, never to the column axis: those all group by rows[0] first, so they select the same page and their cells line up.

Security

The layout is user-editable, so every field is checked twice:

  • The component only offers what dimensions()/measures() (Livewire) or the declared columns (Vue/Svelte) list.
  • The engine re-checks each field against the definition's allow-list. Grouping requires filterable, and a measure requires aggregatable, so a tampered request cannot pivot on an undeclared column or aggregate one that was never marked aggregatable.

Accessibility

Dragging is an enhancement, not the only path. Each bucket has a <select> that does the same thing, every chip has a real remove <button>, and the drop zones are marked so they are not announced as controls. See Selection, keyboard & clipboard.