Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions docs/GRID_SORT_DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Result Grid Sorting — Client-Side Primitive Sort Design

> **Status**: Implemented
> **Date**: 2026-10-08
> **Scope**: `DataGrid` (query results) and `DataTableView` (table data browser)

## Executive Summary

The result grids used to sort by **re-executing the user's SQL** on the backend
(`execute_sorted_query` → `SELECT * FROM (…) ORDER BY …`). That path only works
for plain parsable `SELECT`s, re-runs potentially expensive queries on every
header click, and gave the table browser no sort at all.

This design moves sorting to the **frontend, over the rows already returned**.
It is instant, works for any result set, and is deliberately limited to
**primitive columns** (text / number / boolean / date / NULL). JSON documents,
arrays and BLOBs stay unsortable because they have no meaningful client-side
total ordering.

```
header click ──▶ useDataGridSort (SortState) ──▶ sortRowsByState(rows)
│
primitive-only comparator
▼
sortedRows (computed)
▼
virtual rows / table rows render
```

## 1. What counts as sortable

A column is sortable when **both** hold:

1. Its declared SQL type is primitive —
`/^(?:JSONB?|ARRAY|BYTEA|BLOB|BINARY|VARBINARY|IMAGE|GEOMETRY|GEOGRAPHY|HSTORE|XML)/i`
disqualifies it.
2. Every returned cell is primitive (`string | number | boolean | null | undefined`).
A single object/array cell disqualifies the whole column, because a mixed
column cannot be ordered consistently.

Non-sortable headers render as plain labels with a tooltip; the header context
menu disables its sort entries and explains why.

## 2. Comparison rules

`compareSortValues(a, b, columnType)` resolves an ascending order:

| Kind | Detection | Ordering |
| --- | --- | --- |
| number | numeric SQL type or `typeof value === 'number'` | `Number()` compare; numeric strings (`bigint`) compare numerically |
| boolean | `BOOL`/`BIT` type or boolean value | `false < true`; `t/f/1/0/y/n/yes/no` accepted |
| date | `DATE`/`TIMESTAMP`/`TIME` type | `Date.parse` when both parse, otherwise string order |
| string | fallback | `localeCompare(…, { numeric: true, sensitivity: 'base' })` |

- **NULL/undefined** sort after real values in ascending order. Descending is a
pure reversal, so NULLs come first — matching PostgreSQL's default.
- Comparisons never inspect non-primitive values; those columns are filtered out
before any comparison happens.

## 3. Sorting state

`useDataGridSort` owns an ordered `SortState = { column, direction }[]`:

- Click (no modifier): ASC → DESC → cleared, replacing any existing rules.
- Shift-click: append, cycling ASC → DESC → removed (multi-column sort).
- Context menu: `setSort(column, direction)` sets one direction while keeping
other rules.
- `pruneSort(validColumns)` drops rules for columns that disappeared or became
unsortable (e.g. after running a different query).

`sortRowsByState(rows, sortState, columnTypes)`:

- returns a **new array** (input is never mutated),
- applies every rule in priority order,
- is stable: ties keep their original index,
- short-circuits when there are no rules or no rows.

## 4. Component wiring

### `DataGrid` (query results)

- `sortedRows` is the single source of truth for rendering, row actions,
copy/export and the status bar.
- Header clicks call `handleHeaderSort`; the context menu is told `sortable`.
- Selection is cleared whenever rows, columns or the sort state change, because
selection is positional.
- The status bar shows `id ↑, name ↓` for active rules.
- `DataGrid` no longer emits `sortChange`; sorting is internal.

### `QueryResultPanel`

- Sort no longer round-trips. `reExecuteFiltered` only re-executes for filters,
which still change the result set itself.
- `activeSort`, `handleSortChange` and the `@sort-change` binding are gone.

### `DataTableView` (table browser)

- Same composable and `sortedRows`, scoped to the page that was fetched
(`get_table_data` paginates server-side).
- Sort resets when the table/connection changes and survives page changes.
- CSV export and batch delete read from `sortedRows`, so they match the view.

## 5. Non-goals / limitations

- **No global sort in the table browser.** The current page is sorted; a
cross-page sort would need `ORDER BY` pushed into `get_table_data`.
- **No sorting of JSON/BLOB columns.** Type-aware ordering for those types is a
database concern, not a client one.
- The backend `execute_sorted_query` still accepts `sort` rules; the frontend
simply always sends `[]` now.

## 6. Testing

- `src/__tests__/gridSort.test.ts` — sortability detection, per-kind
comparison, NULL placement, multi-column priority, stability, immutability.
- `src/__tests__/useDataGridSort.test.ts` — click/shift cycling, `setSort`,
`pruneSort`.
160 changes: 160 additions & 0 deletions src/__tests__/gridSort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { SortState } from '@/types/grid'
import {
collectSortableColumns,
compareSortValues,
formatSortState,
isPrimitiveValue,
isSortableColumn,
sortRowsByState,
} from '@/utils/gridSort'

type Row = Record<string, unknown>

describe('isPrimitiveValue', () => {
it('accepts primitives and null', () => {
expect(isPrimitiveValue('a')).toBe(true)
expect(isPrimitiveValue(1)).toBe(true)
expect(isPrimitiveValue(true)).toBe(true)
expect(isPrimitiveValue(null)).toBe(true)
expect(isPrimitiveValue(undefined)).toBe(true)
})

it('rejects objects and arrays', () => {
expect(isPrimitiveValue({ a: 1 })).toBe(false)
expect(isPrimitiveValue([1, 2])).toBe(false)
})
})

describe('isSortableColumn', () => {
it('allows primitive columns', () => {
const rows: Row[] = [{ id: 1 }, { id: 2 }, { id: null }]
expect(isSortableColumn('id', rows, { id: 'integer' })).toBe(true)
})

it('rejects declared JSON/BLOB column types', () => {
const rows: Row[] = [{ payload: '{"a":1}' }, { payload: '"x"' }]
expect(isSortableColumn('payload', rows, { payload: 'jsonb' })).toBe(false)
expect(isSortableColumn('payload', rows, { payload: 'BYTEA' })).toBe(false)
expect(isSortableColumn('payload', rows, { payload: 'blob' })).toBe(false)
})

it('rejects columns holding object or array cells', () => {
const rows: Row[] = [{ payload: 'ok' }, { payload: { a: 1 } }]
expect(isSortableColumn('payload', rows, { payload: 'text' })).toBe(false)
})

it('treats an empty result set as sortable', () => {
expect(isSortableColumn('id', [], { id: 'integer' })).toBe(true)
})

it('collects only sortable columns', () => {
const rows: Row[] = [{ id: 1, meta: { a: 1 }, label: 'x' }]
const sortable = collectSortableColumns(['id', 'meta', 'label'], rows, { meta: 'jsonb' })
expect([...sortable]).toEqual(['id', 'label'])
})
})

describe('compareSortValues', () => {
it('orders numbers numerically', () => {
expect(compareSortValues(2, 10, 'integer')).toBeLessThan(0)
expect(compareSortValues(10, 2, 'integer')).toBeGreaterThan(0)
expect(compareSortValues(2, 2, 'integer')).toBe(0)
})

it('compares numeric strings with a numeric column type', () => {
expect(compareSortValues('9', '10', 'bigint')).toBeLessThan(0)
})

it('orders booleans false before true', () => {
expect(compareSortValues(false, true, 'boolean')).toBeLessThan(0)
expect(compareSortValues('t', 'f', 'bool')).toBeGreaterThan(0)
})

it('orders dates chronologically', () => {
expect(compareSortValues('2024-01-01', '2024-06-01', 'date')).toBeLessThan(0)
expect(compareSortValues('2024-06-01T00:00:00Z', '2024-06-01T00:00:00Z', 'timestamp')).toBe(0)
})

it('sorts nulls after real values in ascending order', () => {
expect(compareSortValues(null, 1)).toBeGreaterThan(0)
expect(compareSortValues(1, null)).toBeLessThan(0)
expect(compareSortValues(null, null)).toBe(0)
})

it('uses numeric-aware, case-insensitive string comparison', () => {
expect(compareSortValues('item2', 'item10')).toBeLessThan(0)
expect(compareSortValues('Apple', 'apple')).toBe(0)
})
})

describe('sortRowsByState', () => {
const rows: Row[] = [
{ id: 3, name: 'carol' },
{ id: 1, name: 'alice' },
{ id: 2, name: 'bob' },
]

it('returns a copy when no sort rules are active', () => {
const result = sortRowsByState(rows, [])
expect(result).toEqual(rows)
expect(result).not.toBe(rows)
})

it('sorts ascending without mutating the input', () => {
const result = sortRowsByState(rows, [{ column: 'id', direction: 'ASC' }])
expect(result.map(r => r.id)).toEqual([1, 2, 3])
expect(rows.map(r => r.id)).toEqual([3, 1, 2])
})

it('sorts descending', () => {
const result = sortRowsByState(rows, [{ column: 'id', direction: 'DESC' }])
expect(result.map(r => r.id)).toEqual([3, 2, 1])
})

it('applies multiple rules in priority order', () => {
const data: Row[] = [
{ dept: 'b', age: 30 },
{ dept: 'a', age: 40 },
{ dept: 'a', age: 20 },
]
const state: SortState = [
{ column: 'dept', direction: 'ASC' },
{ column: 'age', direction: 'DESC' },
]
const result = sortRowsByState(data, state)
expect(result.map(r => `${r.dept}${r.age}`)).toEqual(['a40', 'a20', 'b30'])
})

it('keeps the original order for equal keys', () => {
const data: Row[] = [
{ group: 'x', seq: 1 },
{ group: 'x', seq: 2 },
{ group: 'x', seq: 3 },
]
const result = sortRowsByState(data, [{ column: 'group', direction: 'ASC' }])
expect(result.map(r => r.seq)).toEqual([1, 2, 3])
})

it('puts nulls last ascending and first descending', () => {
const data: Row[] = [{ n: 2 }, { n: null }, { n: 1 }]
expect(sortRowsByState(data, [{ column: 'n', direction: 'ASC' }]).map(r => r.n)).toEqual([1, 2, null])
expect(sortRowsByState(data, [{ column: 'n', direction: 'DESC' }]).map(r => r.n)).toEqual([null, 2, 1])
})

it('uses the declared column type for ordering', () => {
const data: Row[] = [{ v: '10' }, { v: '9' }]
expect(sortRowsByState(data, [{ column: 'v', direction: 'ASC' }], { v: 'bigint' }).map(r => r.v))
.toEqual(['9', '10'])
})
})

describe('formatSortState', () => {
it('renders column and direction', () => {
expect(formatSortState([{ column: 'id', direction: 'ASC' }, { column: 'name', direction: 'DESC' }]))
.toBe('id ↑, name ↓')
})

it('renders an empty string without rules', () => {
expect(formatSortState([])).toBe('')
})
})
54 changes: 54 additions & 0 deletions src/__tests__/useDataGridSort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useDataGridSort } from '@/composables/useDataGridSort'

describe('useDataGridSort', () => {
it('cycles a single column ASC → DESC → cleared', () => {
const sort = useDataGridSort()

sort.toggleSort('id')
expect(sort.sortState.value).toEqual([{ column: 'id', direction: 'ASC' }])

sort.toggleSort('id')
expect(sort.sortState.value).toEqual([{ column: 'id', direction: 'DESC' }])

sort.toggleSort('id')
expect(sort.sortState.value).toEqual([])
})

it('replaces the previous column when shift is not held', () => {
const sort = useDataGridSort()
sort.toggleSort('id')
sort.toggleSort('name')
expect(sort.sortState.value).toEqual([{ column: 'name', direction: 'ASC' }])
})

it('appends columns for multi-column sort when shift is held', () => {
const sort = useDataGridSort()
sort.toggleSort('id')
sort.toggleSort('name', true)
expect(sort.sortState.value).toEqual([
{ column: 'id', direction: 'ASC' },
{ column: 'name', direction: 'ASC' },
])
expect(sort.getSortPriority('name')).toBe(2)
})

it('sets an explicit direction without dropping other rules', () => {
const sort = useDataGridSort()
sort.toggleSort('id')
sort.toggleSort('name', true)
sort.setSort('id', 'DESC')
expect(sort.sortState.value).toEqual([
{ column: 'id', direction: 'DESC' },
{ column: 'name', direction: 'ASC' },
])
})

it('prunes rules for columns that are no longer available', () => {
const sort = useDataGridSort()
sort.toggleSort('id')
sort.toggleSort('name', true)
sort.pruneSort(['name'])
expect(sort.sortState.value).toEqual([{ column: 'name', direction: 'ASC' }])
expect(sort.getSortDirection('id')).toBeNull()
})
})
Loading
Loading