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:
parent
2aa9e0fcdd
commit
21388b7646
@ -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)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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}`),
|
||||||
|
|||||||
@ -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])
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user