mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 16:44:05 +08:00
sort cursor pagination
This commit is contained in:
@@ -204,32 +204,100 @@ export class BaseRowRepo {
|
|||||||
query = this.applyFilter(query, filter, propertyTypeMap);
|
query = this.applyFilter(query, filter, propertyTypeMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply sorts
|
// Build cursor-compatible sort fields.
|
||||||
for (const sort of sorts) {
|
// COALESCE sort expressions so NULLs never reach the cursor encoder/comparator.
|
||||||
query = this.applySort(query, sort, propertyTypeMap);
|
// ASC NULLS LAST → COALESCE(expr, <high sentinel>)
|
||||||
|
// DESC NULLS LAST → COALESCE(expr, <low sentinel>)
|
||||||
|
const sortMeta: Array<{
|
||||||
|
alias: string;
|
||||||
|
expression: ReturnType<typeof sql>;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
isNumeric: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < sorts.length; i++) {
|
||||||
|
const sort = sorts[i];
|
||||||
|
const type = propertyTypeMap.get(sort.propertyId);
|
||||||
|
if (!type) continue;
|
||||||
|
|
||||||
|
const dir = (sort.direction === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc';
|
||||||
|
const alias = `s${i}`;
|
||||||
|
let expression: ReturnType<typeof sql>;
|
||||||
|
let isNumeric = false;
|
||||||
|
|
||||||
|
const systemCol = SYSTEM_COLUMN_MAP[type];
|
||||||
|
if (systemCol) {
|
||||||
|
// System columns (createdAt, updatedAt) are NOT NULL — no COALESCE needed
|
||||||
|
expression = sql`"${sql.raw(systemCol)}"`;
|
||||||
|
} else if (type === 'number') {
|
||||||
|
isNumeric = true;
|
||||||
|
const sentinel = dir === 'asc' ? "'Infinity'::numeric" : "'-Infinity'::numeric";
|
||||||
|
expression = sql`COALESCE((cells->>'${sql.raw(sort.propertyId)}')::numeric, ${sql.raw(sentinel)})`;
|
||||||
|
} else {
|
||||||
|
// Text, date, select, etc.
|
||||||
|
const sentinel = dir === 'asc' ? 'chr(1114111)' : "''";
|
||||||
|
expression = sql`COALESCE(cells->>'${sql.raw(sort.propertyId)}', ${sql.raw(sentinel)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sortMeta.push({ alias, expression, direction: dir, isNumeric });
|
||||||
|
query = query.select(expression.as(alias)) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always add position, id as tiebreaker
|
// Cursor pagination fields: sort aliases + position + id tiebreakers.
|
||||||
query = query.orderBy('position', 'asc').orderBy('id', 'asc');
|
// executeWithCursorPagination applies ORDER BY and builds the keyset WHERE from these.
|
||||||
|
const fields = [
|
||||||
|
...sortMeta.map(({ alias, expression, direction }) => ({
|
||||||
|
expression,
|
||||||
|
direction,
|
||||||
|
key: alias,
|
||||||
|
})),
|
||||||
|
{ expression: 'position' as any, direction: 'asc' as const, key: 'position' },
|
||||||
|
{ expression: 'id' as any, direction: 'asc' as const, key: 'id' },
|
||||||
|
];
|
||||||
|
|
||||||
// Simple limit-based pagination (cursor pagination is not used when filters/sorts are active
|
return executeWithCursorPagination(query as any, {
|
||||||
// because JSONB-based cursor expressions are complex)
|
perPage: pagination.limit,
|
||||||
const limit = pagination.limit ?? 20;
|
cursor: pagination.cursor,
|
||||||
const rows = await query.limit(limit + 1).execute();
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: fields as any,
|
||||||
const hasNextPage = rows.length > limit;
|
encodeCursor: (values: Array<[string, unknown]>) => {
|
||||||
if (hasNextPage) rows.pop();
|
const cursor = new URLSearchParams();
|
||||||
|
for (const [key, value] of values) {
|
||||||
return {
|
if (value === null || value === undefined) {
|
||||||
items: rows,
|
cursor.set(key, '__null__');
|
||||||
meta: {
|
} else if (value instanceof Date) {
|
||||||
limit,
|
cursor.set(key, value.toISOString());
|
||||||
hasNextPage,
|
} else {
|
||||||
hasPrevPage: false,
|
cursor.set(key, String(value));
|
||||||
nextCursor: null,
|
}
|
||||||
prevCursor: null,
|
}
|
||||||
|
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
|
||||||
},
|
},
|
||||||
};
|
decodeCursor: (cursorStr: string, fieldNames: string[]) => {
|
||||||
|
const parsed = new URLSearchParams(
|
||||||
|
Buffer.from(cursorStr, 'base64url').toString('utf8'),
|
||||||
|
);
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const name of fieldNames) {
|
||||||
|
result[name] = parsed.get(name) ?? '';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
parseCursor: (decoded: any) => {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const { alias, isNumeric } of sortMeta) {
|
||||||
|
const val = decoded[alias];
|
||||||
|
if (val === '__null__') {
|
||||||
|
result[alias] = null;
|
||||||
|
} else {
|
||||||
|
result[alias] = isNumeric ? parseFloat(val) : val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.position = decoded.position;
|
||||||
|
result.id = decoded.id;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyFilter(
|
private applyFilter(
|
||||||
|
|||||||
Reference in New Issue
Block a user