/

Custom SQL Reports

Back a grid with an arbitrary read-only SELECT statement via query(), for reports that don't map to a single table.

table() vs. query()

->table($name) backs a grid with one real, writable table. ->query($sql) is the alternative for anything that doesn't map to a single table - joins, aggregates, computed columns spanning several tables:

public function query(string $sql): self

$sql is used verbatim, wrapped as a derived table by the API layer - the exact same trust level where_raw()/subselect()'s own raw-SQL escape hatches have. It must be a plain string you wrote in your own page's PHP, never anything built from request input.

Report mode is read-only

A query-mode grid has no Add/Edit/Delete, no bulk actions, and no inline-edit - it's read-only by nature, since there's no single table a write could unambiguously target. This is enforced both client-side and server-side: the API rejects POST/PUT/DELETE against a query-mode route outright, regardless of what the client sends. Only list/search/sort/paginate/export remain.

columns() and route() are still required

A query-mode grid never opens a database connection of its own to discover columns - its column list is only ever what ->columns() says. ->route() is likewise required since there's no table name to default one from.

A JOIN-based example

$xcrud = Xcrud::get_instance();
$xcrud->route('order-summary');
$xcrud->query(
    'SELECT o.orderNumber, o.orderDate, o.status, c.customerName, c.country,
            COUNT(od.id) AS itemCount,
            SUM(od.quantityOrdered * od.priceEach) AS orderTotal
     FROM orders o
     JOIN customers c ON c.customerNumber = o.customerNumber
     JOIN orderdetails od ON od.orderNumber = o.orderNumber
     GROUP BY o.orderNumber, o.orderDate, o.status, c.customerName, c.country'
);
$xcrud->columns('orderNumber,orderDate,status,customerName,country,itemCount,orderTotal');
$xcrud->order_by('orderTotal', 'desc');
$xcrud->title('Order Summary Report');

echo $xcrud->render();

$sql's own column aliases and ->columns()'s names are never cross-checked against each other (no DB access happens at render time) - a mismatch surfaces as an error on the grid's first real request, not a render-time exception the way an unknown column name in order_by()/label()/where() would. Double-check the two lists by hand.

A trailing ORDER BY inside $sql with no LIMIT may be silently dropped once wrapped as a derived table - harmless, since the grid's own sort/->order_by() always applies an outer ORDER BY regardless, but don't rely on an inner one doing anything on its own.