110 lines
2.5 KiB
TypeScript
110 lines
2.5 KiB
TypeScript
|
|
import {
|
||
|
|
and,
|
||
|
|
asc,
|
||
|
|
desc,
|
||
|
|
eq,
|
||
|
|
ilike,
|
||
|
|
isNull,
|
||
|
|
or,
|
||
|
|
sql,
|
||
|
|
type SQL,
|
||
|
|
} from 'drizzle-orm';
|
||
|
|
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
|
||
|
|
import { db } from './index';
|
||
|
|
|
||
|
|
export interface BuildListQueryOptions {
|
||
|
|
table: PgTable;
|
||
|
|
portIdColumn: PgColumn;
|
||
|
|
portId: string;
|
||
|
|
idColumn: PgColumn;
|
||
|
|
updatedAtColumn: PgColumn;
|
||
|
|
filters?: SQL[];
|
||
|
|
sort?: { column: PgColumn; direction: 'asc' | 'desc' };
|
||
|
|
page: number;
|
||
|
|
pageSize: number;
|
||
|
|
searchColumns?: PgColumn[];
|
||
|
|
searchTerm?: string;
|
||
|
|
includeArchived?: boolean;
|
||
|
|
archivedAtColumn?: PgColumn;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ListResult<T> {
|
||
|
|
data: T[];
|
||
|
|
total: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generic Drizzle paginated query builder with port-scoping.
|
||
|
|
*
|
||
|
|
* - Port scoping is always the first condition in the AND chain.
|
||
|
|
* - `archivedAt IS NULL` by default (unless `includeArchived` is true).
|
||
|
|
* - Deterministic secondary sort: `updatedAt DESC, id DESC`.
|
||
|
|
*/
|
||
|
|
export async function buildListQuery<T>(
|
||
|
|
opts: BuildListQueryOptions,
|
||
|
|
): Promise<ListResult<T>> {
|
||
|
|
const {
|
||
|
|
table,
|
||
|
|
portIdColumn,
|
||
|
|
portId,
|
||
|
|
idColumn,
|
||
|
|
updatedAtColumn,
|
||
|
|
filters = [],
|
||
|
|
sort,
|
||
|
|
page,
|
||
|
|
pageSize,
|
||
|
|
searchColumns = [],
|
||
|
|
searchTerm,
|
||
|
|
includeArchived = false,
|
||
|
|
archivedAtColumn,
|
||
|
|
} = opts;
|
||
|
|
|
||
|
|
const conditions: SQL[] = [eq(portIdColumn, portId)];
|
||
|
|
|
||
|
|
// Exclude archived by default
|
||
|
|
if (!includeArchived && archivedAtColumn) {
|
||
|
|
conditions.push(isNull(archivedAtColumn));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Full-text search across multiple columns via ILIKE
|
||
|
|
if (searchTerm && searchColumns.length > 0) {
|
||
|
|
const searchConditions = searchColumns.map((col) =>
|
||
|
|
ilike(col, `%${searchTerm}%`),
|
||
|
|
);
|
||
|
|
conditions.push(or(...searchConditions)!);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Append caller-supplied filters
|
||
|
|
conditions.push(...filters);
|
||
|
|
|
||
|
|
const where = and(...conditions);
|
||
|
|
|
||
|
|
// Count total
|
||
|
|
const countResult = await db
|
||
|
|
.select({ count: sql<number>`count(*)::int` })
|
||
|
|
.from(table)
|
||
|
|
.where(where);
|
||
|
|
const total = countResult[0]?.count ?? 0;
|
||
|
|
|
||
|
|
// Build order by: user sort + deterministic secondary sort
|
||
|
|
const orderClauses: SQL[] = [];
|
||
|
|
if (sort) {
|
||
|
|
orderClauses.push(
|
||
|
|
sort.direction === 'asc' ? asc(sort.column) : desc(sort.column),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
orderClauses.push(desc(updatedAtColumn), desc(idColumn));
|
||
|
|
|
||
|
|
// Fetch page
|
||
|
|
const offset = (page - 1) * pageSize;
|
||
|
|
const data = await db
|
||
|
|
.select()
|
||
|
|
.from(table)
|
||
|
|
.where(where)
|
||
|
|
.orderBy(...orderClauses)
|
||
|
|
.limit(pageSize)
|
||
|
|
.offset(offset);
|
||
|
|
|
||
|
|
return { data: data as T[], total };
|
||
|
|
}
|