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) => { router.get('/:name/view-data', async (req, res, next) => {
try { 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}`; const viewName = `dfv.${req.params.name}`;
// Check view exists // Check view exists
@ -298,8 +298,23 @@ module.exports = (pool) => {
return res.json({ exists: false, rows: [] }); 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( const result = await pool.query(
`SELECT * FROM ${viewName} LIMIT $1 OFFSET $2`, `SELECT * FROM ${viewName}${orderClause} LIMIT $1 OFFSET $2`,
[parseInt(limit), parseInt(offset)] [parseInt(limit), parseInt(offset)]
); );

View File

@ -63,7 +63,11 @@ export const api = {
reprocess: (name) => request('POST', `/sources/${name}/reprocess`), reprocess: (name) => request('POST', `/sources/${name}/reprocess`),
generateView: (name) => request('POST', `/sources/${name}/view`), generateView: (name) => request('POST', `/sources/${name}/view`),
getFields: (name) => request('GET', `/sources/${name}/fields`), 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 // Rules
getRules: (source) => request('GET', `/rules/source/${source}`), 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' import { api } from '../api'
const DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/ const DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/
@ -30,13 +30,13 @@ export default function Records({ source }) {
if (!source) return if (!source) return
setOffset(0) setOffset(0)
setSort({ col: null, dir: 'asc' }) setSort({ col: null, dir: 'asc' })
load(0) load(0, null, 'asc')
}, [source]) }, [source])
async function load(off) { async function load(off, col, dir) {
setLoading(true) setLoading(true)
try { try {
const res = await api.getViewData(source, LIMIT, off) const res = await api.getViewData(source, LIMIT, off, col, dir)
setExists(res.exists) setExists(res.exists)
setRows(res.rows) setRows(res.rows)
} catch (err) { } catch (err) {
@ -47,24 +47,16 @@ export default function Records({ source }) {
} }
function toggleSort(col) { function toggleSort(col) {
setSort(s => s.col === col const next = sort.col === col
? { col, dir: s.dir === 'asc' ? 'desc' : 'asc' } ? { col, dir: sort.dir === 'asc' ? 'desc' : 'asc' }
: { col, dir: 'asc' } : { col, dir: 'asc' }
) setSort(next)
setOffset(0)
load(0, next.col, next.dir)
} }
const sorted = useMemo(() => { function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o, sort.col, sort.dir) }
if (!sort.col) return rows function next() { const o = offset + LIMIT; setOffset(o); load(o, sort.col, sort.dir) }
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) }
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div> 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> </tr>
</thead> </thead>
<tbody> <tbody>
{sorted.map((row, i) => ( {rows.map((row, i) => (
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50"> <tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
{cols.map((col, j) => { {cols.map((col, j) => {
const formatted = formatVal(row[col]) const formatted = formatVal(row[col])