fix sorting

This commit is contained in:
Lakee Sivaraya
2026-01-15 19:30:25 -08:00
parent 1a13762617
commit 44909964b7
3 changed files with 88 additions and 26 deletions

View File

@@ -17,12 +17,7 @@ import {
validateUniqueConstraints,
} from '@/lib/table'
import { buildFilterClause, buildSortClause } from '@/lib/table/query-builder'
import {
checkAccessOrRespond,
checkAccessWithFullTable,
checkTableAccess,
verifyTableWorkspace,
} from '../../utils'
import { checkAccessOrRespond, checkAccessWithFullTable, verifyTableWorkspace } from '../../utils'
const logger = createLogger('TableRowsAPI')
@@ -378,25 +373,22 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
offset,
})
// Check table access (centralized access control)
const accessCheck = await checkTableAccess(tableId, authResult.userId)
// Check table access with full table data (includes schema for type-aware sorting)
const accessResult = await checkAccessWithFullTable(
tableId,
authResult.userId,
requestId,
'read'
)
if (accessResult instanceof NextResponse) return accessResult
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Table not found: ${tableId}`)
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${authResult.userId} attempted to query rows from unauthorized table ${tableId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const { table } = accessResult
// Security check: verify workspaceId matches the table's workspace
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${accessCheck.table.workspaceId}`
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
@@ -426,9 +418,10 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
.from(userTableRows)
.where(and(...baseConditions))
// Apply sorting
// Apply sorting with type-aware column handling
if (validated.sort) {
const sortClause = buildSortClause(validated.sort, 'user_table_rows')
const schema = table.schema as TableSchema
const sortClause = buildSortClause(validated.sort, 'user_table_rows', schema.columns)
if (sortClause) {
query = query.orderBy(sortClause) as typeof query
}

View File

@@ -227,6 +227,38 @@ describe('Query Builder', () => {
expect(() => buildSortClause(sort, tableName)).toThrow('Invalid sort direction')
})
it('should handle numeric column type for proper numeric sorting', () => {
const sort = { salary: 'desc' as const }
const columns = [{ name: 'salary', type: 'number' as const }]
const result = buildSortClause(sort, tableName, columns)
expect(result).toBeDefined()
})
it('should handle date column type for chronological sorting', () => {
const sort = { birthDate: 'asc' as const }
const columns = [{ name: 'birthDate', type: 'date' as const }]
const result = buildSortClause(sort, tableName, columns)
expect(result).toBeDefined()
})
it('should use text sorting for string columns', () => {
const sort = { name: 'asc' as const }
const columns = [{ name: 'name', type: 'string' as const }]
const result = buildSortClause(sort, tableName, columns)
expect(result).toBeDefined()
})
it('should fall back to text sorting when column type is unknown', () => {
const sort = { unknownField: 'asc' as const }
// No columns provided
const result = buildSortClause(sort, tableName)
expect(result).toBeDefined()
})
})
describe('Field Name Validation', () => {

View File

@@ -8,7 +8,7 @@
import type { SQL } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { NAME_PATTERN } from './constants'
import type { ConditionOperators, Filter, JsonValue, Sort } from './types'
import type { ColumnDefinition, ConditionOperators, Filter, JsonValue, Sort } from './types'
/**
* Whitelist of allowed operators for query filtering.
@@ -100,15 +100,26 @@ export function buildFilterClause(filter: Filter, tableName: string): SQL | unde
*
* @param sort - Sort object with field names and directions
* @param tableName - Table name for the query (e.g., 'user_table_rows')
* @param columns - Optional column definitions for type-aware sorting
* @returns SQL ORDER BY clause or undefined if no sort specified
* @throws Error if field name is invalid
*
* @example
* buildSortClause({ name: 'asc', age: 'desc' }, 'user_table_rows')
* // Returns: ORDER BY data->>'name' ASC, data->>'age' DESC
*
* @example
* // With column types for proper numeric sorting
* buildSortClause({ salary: 'desc' }, 'user_table_rows', [{ name: 'salary', type: 'number' }])
* // Returns: ORDER BY (data->>'salary')::numeric DESC NULLS LAST
*/
export function buildSortClause(sort: Sort, tableName: string): SQL | undefined {
export function buildSortClause(
sort: Sort,
tableName: string,
columns?: ColumnDefinition[]
): SQL | undefined {
const clauses: SQL[] = []
const columnTypeMap = new Map(columns?.map((col) => [col.name, col.type]))
for (const [field, direction] of Object.entries(sort)) {
validateFieldName(field)
@@ -117,7 +128,8 @@ export function buildSortClause(sort: Sort, tableName: string): SQL | undefined
throw new Error(`Invalid sort direction "${direction}". Must be "asc" or "desc".`)
}
clauses.push(buildSortFieldClause(tableName, field, direction))
const columnType = columnTypeMap.get(field)
clauses.push(buildSortFieldClause(tableName, field, direction, columnType))
}
return clauses.length > 0 ? sql.join(clauses, sql.raw(', ')) : undefined
@@ -319,8 +331,19 @@ function buildContainsClause(tableName: string, field: string, value: string): S
/**
* Builds a single ORDER BY clause for a field.
* Timestamp fields use direct column access, others use JSONB text extraction.
* Numeric and date columns are cast to appropriate types for correct sorting.
*
* @param tableName - The table name
* @param field - The field name to sort by
* @param direction - Sort direction ('asc' or 'desc')
* @param columnType - Optional column type for type-aware sorting
*/
function buildSortFieldClause(tableName: string, field: string, direction: 'asc' | 'desc'): SQL {
function buildSortFieldClause(
tableName: string,
field: string,
direction: 'asc' | 'desc',
columnType?: string
): SQL {
const escapedField = field.replace(/'/g, "''")
const directionSql = direction.toUpperCase()
@@ -328,5 +351,19 @@ function buildSortFieldClause(tableName: string, field: string, direction: 'asc'
return sql.raw(`${tableName}.${escapedField} ${directionSql}`)
}
return sql.raw(`${tableName}.data->>'${escapedField}' ${directionSql}`)
const jsonbExtract = `${tableName}.data->>'${escapedField}'`
// Cast to appropriate type for correct sorting
if (columnType === 'number') {
// Cast to numeric, with NULLS LAST to handle null/invalid values
return sql.raw(`(${jsonbExtract})::numeric ${directionSql} NULLS LAST`)
}
if (columnType === 'date') {
// Cast to timestamp for chronological sorting
return sql.raw(`(${jsonbExtract})::timestamp ${directionSql} NULLS LAST`)
}
// Default: sort as text (for string, boolean, json, or unknown types)
return sql.raw(`${jsonbExtract} ${directionSql}`)
}