From d52066590ca76cb83107b790a779ce582946f377 Mon Sep 17 00:00:00 2001 From: blankll Date: Fri, 9 Oct 2026 00:09:03 +0800 Subject: [PATCH 1/2] fix(grid): consistent sorting for columns with mixed primitive types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate the kind-precedence design into the #171 implementation. Per-cell kind resolution (resolveSortKind on every comparison) breaks two ways on columns holding mixed primitives — routine on SQLite: - the comparator flips kind between passes, so order is unstable - resolveSortKind inspected only the LEFT value, making compare(a,b) !== -compare(b,a) — an asymmetric comparator Kind is now resolved once per column: the declared type wins outright; without one, the highest-precedence kind present is used (number → boolean → date → string; strings are never date-sniffed). NULL ordering, non-primitive column exclusion and the coercers from #171 are unchanged. --- src/__tests__/gridSort.test.ts | 30 +++++++++++++--- src/utils/gridSort.ts | 66 ++++++++++++++++++++++++++++++---- 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/__tests__/gridSort.test.ts b/src/__tests__/gridSort.test.ts index d077bba..b202126 100644 --- a/src/__tests__/gridSort.test.ts +++ b/src/__tests__/gridSort.test.ts @@ -54,11 +54,31 @@ describe('isSortableColumn', () => { }) }) -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) +describe('mixed-type columns', () => { + it('compares an untyped number+string column by one fixed kind (numbers first)', () => { + const rows = [ + { v: 'abc' }, + { v: 10 }, + { v: 2 }, + { v: null }, + ] + const sorted = sortRowsByState(rows, [{ column: 'v', direction: 'ASC' }]) + expect(sorted.map(r => r.v)).toEqual([2, 10, 'abc', null]) + }) + + it('keeps the comparator symmetric on mixed columns', () => { + // per-cell kind resolution made compare(a,b) !== -compare(b,a) here + expect(compareSortValues('10', 9, 'number')).toBeGreaterThan(0) + expect(compareSortValues(9, '10', 'number')).toBeLessThan(0) + }) + + it('declared type wins over value kinds', () => { + const rows = [ + { v: '9' }, + { v: '10' }, + ] + const sorted = sortRowsByState(rows, [{ column: 'v', direction: 'ASC' }], { v: 'integer' }) + expect(sorted.map(r => r.v)).toEqual(['9', '10']) }) it('compares numeric strings with a numeric column type', () => { diff --git a/src/utils/gridSort.ts b/src/utils/gridSort.ts index 25c3f24..7e507ba 100644 --- a/src/utils/gridSort.ts +++ b/src/utils/gridSort.ts @@ -54,18 +54,65 @@ export function collectSortableColumns( type SortValueKind = 'boolean' | 'number' | 'date' | 'string' -/** Resolve how a cell should be compared, preferring the declared column type. */ -function resolveSortKind(value: unknown, columnType?: string): SortValueKind { +/** + * Fixed ordering across kinds, so a column holding mixed primitive types + * (SQLite's dynamic typing makes this routine) still yields ONE comparator. + * The kind is resolved per column, never per cell — per-cell resolution + * breaks comparator symmetry and flips order between passes. + */ +const KIND_PRECEDENCE: Record = { number: 0, boolean: 1, date: 2, string: 3 } + +/** Kind fixed by the declared column type, if it names one. */ +function resolveDeclaredKind(columnType?: string): SortValueKind | null { const type = (columnType ?? '').trim() - if (BOOLEAN_TYPE_PATTERN.test(type) || typeof value === 'boolean') + if (BOOLEAN_TYPE_PATTERN.test(type)) return 'boolean' - if (NUMERIC_TYPE_PATTERN.test(type) || typeof value === 'number') + if (NUMERIC_TYPE_PATTERN.test(type)) return 'number' if (DATE_TYPE_PATTERN.test(type)) return 'date' + return null +} + +/** Kind of a value when no usable type is declared; strings are never date-sniffed. */ +function resolveValueKind(value: unknown): SortValueKind { + if (typeof value === 'boolean') + return 'boolean' + if (typeof value === 'number') + return 'number' return 'string' } +function kindFromToken(kindOrType?: string): SortValueKind { + if (kindOrType && (kindOrType === 'boolean' || kindOrType === 'number' || kindOrType === 'date' || kindOrType === 'string')) + return kindOrType + return resolveDeclaredKind(kindOrType) ?? 'string' +} + +/** One kind for the whole column: declared type wins, otherwise the highest-precedence kind present. */ +function resolveColumnKind( + rows: readonly Record[], + column: string, + columnType?: string, +): SortValueKind { + const declared = resolveDeclaredKind(columnType) + if (declared) + return declared + let best = 'string' as SortValueKind + for (const row of rows) { + const value = row[column] + if (value === null || value === undefined || typeof value === 'object') + continue + const kind = resolveValueKind(value) + if (KIND_PRECEDENCE[kind] < KIND_PRECEDENCE[best]) { + best = kind + if (best === 'number') + break + } + } + return best +} + function toBoolean(value: unknown): boolean { return value === true || value === 1 @@ -102,7 +149,7 @@ function compareDates(a: unknown, b: unknown): number { * Ascending comparison for two primitive cell values. * NULL/undefined always sort after real values in ascending order. */ -export function compareSortValues(a: unknown, b: unknown, columnType?: string): number { +export function compareSortValues(a: unknown, b: unknown, kindOrType?: string): number { const aNull = a === null || a === undefined const bNull = b === null || b === undefined if (aNull && bNull) @@ -112,7 +159,7 @@ export function compareSortValues(a: unknown, b: unknown, columnType?: string): if (bNull) return -1 - switch (resolveSortKind(a, columnType)) { + switch (kindFromToken(kindOrType)) { case 'boolean': return (toBoolean(a) ? 1 : 0) - (toBoolean(b) ? 1 : 0) case 'number': @@ -136,11 +183,16 @@ export function sortRowsByState>( if (rows.length === 0 || sortState.length === 0) return [...rows] + const kinds = new Map(sortState.map(rule => [ + rule.column, + resolveColumnKind(rows, rule.column, columnTypes[rule.column]), + ])) + return rows .map((row, index) => ({ row, index })) .sort((a, b) => { for (const rule of sortState) { - const result = compareSortValues(a.row[rule.column], b.row[rule.column], columnTypes[rule.column]) + const result = compareSortValues(a.row[rule.column], b.row[rule.column], kinds.get(rule.column)) if (result !== 0) return rule.direction === 'DESC' ? -result : result } From 0741a7e9f258ceeab06077e90ff03ac5b5861aee Mon Sep 17 00:00:00 2001 From: blankll Date: Fri, 9 Oct 2026 00:49:36 +0800 Subject: [PATCH 2/2] chore(lint): allow const-arrow top-level declarations per AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global AGENTS.md prefers const xxx = (...) => ... declarations; the antfu preset's top-level-function rule enforced the opposite and blocked the gridSort refactor. Turn the rule off — existing function declarations stay as-is, new code follows AGENTS.md. --- eslint.config.js | 3 +++ src/utils/gridSort.ts | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 97718a1..9f0f19f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,6 +28,9 @@ export default antfu({ 'src-tauri/gen/**', ], rules: { + // AGENTS.md prefers const-arrow declarations; the antfu preset's + // top-level-function rule enforces the opposite + 'antfu/top-level-function': 'off', 'no-console': 'warn', 'unused-imports/no-unused-vars': 'warn', 'style/eol-last': ['error', 'always'], diff --git a/src/utils/gridSort.ts b/src/utils/gridSort.ts index 7e507ba..ba21cc1 100644 --- a/src/utils/gridSort.ts +++ b/src/utils/gridSort.ts @@ -63,7 +63,7 @@ type SortValueKind = 'boolean' | 'number' | 'date' | 'string' const KIND_PRECEDENCE: Record = { number: 0, boolean: 1, date: 2, string: 3 } /** Kind fixed by the declared column type, if it names one. */ -function resolveDeclaredKind(columnType?: string): SortValueKind | null { +const resolveDeclaredKind = (columnType?: string): SortValueKind | null => { const type = (columnType ?? '').trim() if (BOOLEAN_TYPE_PATTERN.test(type)) return 'boolean' @@ -75,7 +75,7 @@ function resolveDeclaredKind(columnType?: string): SortValueKind | null { } /** Kind of a value when no usable type is declared; strings are never date-sniffed. */ -function resolveValueKind(value: unknown): SortValueKind { +const resolveValueKind = (value: unknown): SortValueKind => { if (typeof value === 'boolean') return 'boolean' if (typeof value === 'number') @@ -83,18 +83,18 @@ function resolveValueKind(value: unknown): SortValueKind { return 'string' } -function kindFromToken(kindOrType?: string): SortValueKind { +const kindFromToken = (kindOrType?: string): SortValueKind => { if (kindOrType && (kindOrType === 'boolean' || kindOrType === 'number' || kindOrType === 'date' || kindOrType === 'string')) return kindOrType return resolveDeclaredKind(kindOrType) ?? 'string' } /** One kind for the whole column: declared type wins, otherwise the highest-precedence kind present. */ -function resolveColumnKind( +const resolveColumnKind = ( rows: readonly Record[], column: string, columnType?: string, -): SortValueKind { +): SortValueKind => { const declared = resolveDeclaredKind(columnType) if (declared) return declared