Server-side sorting on Records page

Clicking a column header reloads from page 1 with ORDER BY col ASC/DESC
NULLS LAST passed to the view query. Sort column is validated against
information_schema.columns to prevent injection. Pagination preserves
the active sort across prev/next.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-05 20:31:14 -04:00
parent 2aa9e0fcdd
commit 21388b7646
3 changed files with 34 additions and 23 deletions

View File

@ -284,7 +284,7 @@ module.exports = (pool) => {
router.get('/:name/view-data', async (req, res, next) => {
try {
const { limit = 100, offset = 0 } = req.query;
const { limit = 100, offset = 0, sort_col, sort_dir } = req.query;
const viewName = `dfv.${req.params.name}`;
// Check view exists
@ -298,8 +298,23 @@ module.exports = (pool) => {
return res.json({ exists: false, rows: [] });
}
// Validate sort_col against actual view columns to prevent injection
let orderClause = '';
if (sort_col) {
const cols = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'dfv' AND table_name = $1`,
[req.params.name]
);
const validCols = cols.rows.map(r => r.column_name);
if (validCols.includes(sort_col)) {
const dir = sort_dir === 'desc' ? 'DESC' : 'ASC';
orderClause = ` ORDER BY "${sort_col}" ${dir} NULLS LAST`;
}
}
const result = await pool.query(
`SELECT * FROM ${viewName} LIMIT $1 OFFSET $2`,
`SELECT * FROM ${viewName}${orderClause} LIMIT $1 OFFSET $2`,
[parseInt(limit), parseInt(offset)]
);

View File

@ -63,7 +63,11 @@ export const api = {
reprocess: (name) => request('POST', `/sources/${name}/reprocess`),
generateView: (name) => request('POST', `/sources/${name}/view`),
getFields: (name) => request('GET', `/sources/${name}/fields`),
getViewData: (name, limit = 100, offset = 0) => request('GET', `/sources/${name}/view-data?limit=${limit}&offset=${offset}`),
getViewData: (name, limit = 100, offset = 0, sortCol = null, sortDir = 'asc') => {
const params = new URLSearchParams({ limit, offset })
if (sortCol) { params.set('sort_col', sortCol); params.set('sort_dir', sortDir) }
return request('GET', `/sources/${name}/view-data?${params}`)
},
// Rules
getRules: (source) => request('GET', `/rules/source/${source}`),

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect } from 'react'
import { api } from '../api'
const DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/
@ -30,13 +30,13 @@ export default function Records({ source }) {
if (!source) return
setOffset(0)
setSort({ col: null, dir: 'asc' })
load(0)
load(0, null, 'asc')
}, [source])
async function load(off) {
async function load(off, col, dir) {
setLoading(true)
try {
const res = await api.getViewData(source, LIMIT, off)
const res = await api.getViewData(source, LIMIT, off, col, dir)
setExists(res.exists)
setRows(res.rows)
} catch (err) {
@ -47,24 +47,16 @@ export default function Records({ source }) {
}
function toggleSort(col) {
setSort(s => s.col === col
? { col, dir: s.dir === 'asc' ? 'desc' : 'asc' }
const next = sort.col === col
? { col, dir: sort.dir === 'asc' ? 'desc' : 'asc' }
: { col, dir: 'asc' }
)
setSort(next)
setOffset(0)
load(0, next.col, next.dir)
}
const sorted = useMemo(() => {
if (!sort.col) return rows
return [...rows].sort((a, b) => {
const av = a[sort.col] ?? ''
const bv = b[sort.col] ?? ''
const cmp = String(av).localeCompare(String(bv), undefined, { numeric: true })
return sort.dir === 'asc' ? cmp : -cmp
})
}, [rows, sort])
function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o) }
function next() { const o = offset + LIMIT; setOffset(o); load(o) }
function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o, sort.col, sort.dir) }
function next() { const o = offset + LIMIT; setOffset(o); load(o, sort.col, sort.dir) }
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
@ -115,7 +107,7 @@ export default function Records({ source }) {
</tr>
</thead>
<tbody>
{sorted.map((row, i) => (
{rows.map((row, i) => (
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
{cols.map((col, j) => {
const formatted = formatVal(row[col])