Skip to content

Commit 24b869c

Browse files
committed
feat: add support for sorting in list v2 endpoint
1 parent 23559aa commit 24b869c

File tree

6 files changed

+170
-13
lines changed

6 files changed

+170
-13
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
CREATE OR REPLACE FUNCTION storage.search_v2 (
2+
prefix text,
3+
bucket_name text,
4+
limits int DEFAULT 100,
5+
levels int DEFAULT 1,
6+
start_after text DEFAULT '',
7+
sortcolumn text DEFAULT 'name',
8+
sortorder text DEFAULT 'asc'
9+
) RETURNS TABLE (
10+
key text,
11+
name text,
12+
id uuid,
13+
updated_at timestamptz,
14+
created_at timestamptz,
15+
last_accessed_at timestamptz,
16+
metadata jsonb
17+
)
18+
SECURITY INVOKER
19+
AS $func$
20+
DECLARE
21+
sort_col text;
22+
sort_ord text;
23+
cursor_op text;
24+
cursor_expr text;
25+
collate_clause text := '';
26+
BEGIN
27+
-- Validate sortorder
28+
sort_ord := lower(sortorder);
29+
IF sort_ord NOT IN ('asc', 'desc') THEN
30+
sort_ord := 'asc';
31+
END IF;
32+
33+
-- Determine cursor comparison operator
34+
IF sort_ord = 'asc' THEN
35+
cursor_op := '>';
36+
ELSE
37+
cursor_op := '<';
38+
END IF;
39+
40+
-- Validate sortcolumn
41+
sort_col := lower(sortcolumn);
42+
IF sort_col IN ('updated_at', 'created_at', 'last_accessed_at') THEN
43+
cursor_expr := format('($5 = '''' OR %I %s $5::timestamptz)', sort_col, cursor_op);
44+
ELSE
45+
sort_col := 'name';
46+
collate_clause := ' COLLATE "C"';
47+
cursor_expr := format('($5 = '''' OR %I%s %s $5)', sort_col, collate_clause, cursor_op);
48+
END IF;
49+
50+
RETURN QUERY EXECUTE format(
51+
$sql$
52+
SELECT * FROM (
53+
(
54+
SELECT
55+
split_part(name, '/', $4) AS key,
56+
name || '/' AS name,
57+
NULL::uuid AS id,
58+
NULL::timestamptz AS updated_at,
59+
NULL::timestamptz AS created_at,
60+
NULL::timestamptz AS last_accessed_at,
61+
NULL::jsonb AS metadata
62+
FROM storage.prefixes
63+
WHERE name COLLATE "C" LIKE $1 || '%%'
64+
AND bucket_id = $2
65+
AND level = $4
66+
AND %s
67+
ORDER BY %I%s %s
68+
LIMIT $3
69+
)
70+
UNION ALL
71+
(
72+
SELECT
73+
split_part(name, '/', $4) AS key,
74+
name,
75+
id,
76+
updated_at,
77+
created_at,
78+
last_accessed_at,
79+
metadata
80+
FROM storage.objects
81+
WHERE name COLLATE "C" LIKE $1 || '%%'
82+
AND bucket_id = $2
83+
AND level = $4
84+
AND %s
85+
ORDER BY %I%s %s
86+
LIMIT $3
87+
)
88+
) obj
89+
ORDER BY %I %s
90+
LIMIT $3
91+
$sql$,
92+
cursor_expr, -- prefixes WHERE
93+
sort_col, collate_clause, sort_ord, -- prefixes ORDER BY
94+
cursor_expr, -- objects WHERE
95+
sort_col, collate_clause, sort_ord, -- objects ORDER BY
96+
sort_col, collate_clause, sort_ord -- final ORDER BY
97+
)
98+
USING prefix, bucket_name, limits, levels, start_after;
99+
END;
100+
$func$ LANGUAGE plpgsql STABLE;

src/http/routes/object/listObjectsV2.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ const searchRequestBodySchema = {
2323
limit: { type: 'integer', minimum: 1, examples: [10] },
2424
cursor: { type: 'string' },
2525
with_delimiter: { type: 'boolean' },
26+
sortBy: {
27+
type: 'object',
28+
properties: {
29+
column: { type: 'string', enum: ['name', 'updated_at', 'created_at', 'last_accessed_at'] },
30+
order: { type: 'string', enum: ['asc', 'desc'] },
31+
},
32+
required: ['column'],
33+
},
2634
},
2735
} as const
2836
interface searchRequestInterface extends AuthenticatedRequest {
@@ -57,13 +65,14 @@ export default async function routes(fastify: FastifyInstance) {
5765
}
5866

5967
const { bucketName } = request.params
60-
const { limit, with_delimiter, cursor, prefix } = request.body
68+
const { limit, with_delimiter, cursor, prefix, sortBy } = request.body
6169

6270
const results = await request.storage.from(bucketName).listObjectsV2({
6371
prefix,
6472
delimiter: with_delimiter ? '/' : undefined,
6573
maxKeys: limit,
6674
cursor,
75+
sortBy,
6776
})
6877

6978
return response.status(200).send(results)

src/internal/database/migrations/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ export const DBMigration = {
3737
'optimise-existing-functions': 36,
3838
'add-bucket-name-length-trigger': 37,
3939
'iceberg-catalog-flag-on-buckets': 38,
40+
'add-search-v2-sort-support': 39,
4041
}

src/storage/database/adapter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ export interface Database {
105105
nextToken?: string
106106
maxKeys?: number
107107
startAfter?: string
108+
sortBy?: {
109+
column?: string
110+
order?: string
111+
}
108112
}
109113
): Promise<Obj[]>
110114

src/storage/database/knex.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,27 +269,44 @@ export class StorageKnexDB implements Database {
269269
nextToken?: string
270270
maxKeys?: number
271271
startAfter?: string
272+
sortBy?: {
273+
column?: string
274+
order?: string
275+
}
272276
}
273277
) {
274278
return this.runQuery('ListObjectsV2', async (knex) => {
275279
if (!options?.delimiter) {
276280
const query = knex
277281
.table('objects')
278282
.where('bucket_id', bucketId)
279-
.select(['id', 'name', 'metadata', 'updated_at'])
283+
.select(['id', 'name', 'metadata', 'updated_at', 'created_at', 'last_accessed_at'])
280284
.limit(options?.maxKeys || 100)
281285

286+
// only allow these values for sort columns, "name" is excluded intentionally so it will use the default value (to include collate)
287+
const allowedSortColumns = new Set(['updated_at', 'created_at', 'last_accessed_at'])
288+
const allowedSortOrders = new Set(['asc', 'desc'])
289+
const sortColumn =
290+
options?.sortBy?.column && allowedSortColumns.has(options.sortBy.column)
291+
? options.sortBy.column
292+
: 'name COLLATE "C"'
293+
const sortOrder =
294+
options?.sortBy?.order && allowedSortOrders.has(options.sortBy.order)
295+
? options.sortBy.order
296+
: 'asc'
297+
282298
// knex typing is wrong, it doesn't accept a knex.raw on orderBy, even though is totally legit
283299
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
284300
// @ts-ignore
285-
query.orderBy(knex.raw('name COLLATE "C"'))
301+
query.orderBy(knex.raw(`${sortColumn}`), sortOrder)
286302

287303
if (options?.prefix) {
288304
query.where('name', 'like', `${options.prefix}%`)
289305
}
290306

291307
if (options?.nextToken) {
292-
query.andWhere(knex.raw('name COLLATE "C" > ?', [options?.nextToken]))
308+
const pageOperator = sortOrder === 'asc' ? '>' : '<'
309+
query.andWhere(knex.raw(`${sortColumn} ${pageOperator} ?`, [options.nextToken]))
293310
}
294311

295312
return query
@@ -302,13 +319,21 @@ export class StorageKnexDB implements Database {
302319
}
303320

304321
if (useNewSearchVersion2 && options?.delimiter === '/') {
322+
let paramPlaceholders = '?,?,?,?,?'
323+
const sortParams: string[] = []
324+
// this migration adds 2 more parameters to search v2 support sorting
325+
if (await tenantHasMigrations(this.tenantId, 'add-search-v2-sort-support')) {
326+
paramPlaceholders += ',?,?'
327+
sortParams.push(options?.sortBy?.column || 'name', options?.sortBy?.order || 'asc')
328+
}
305329
const levels = !options?.prefix ? 1 : options.prefix.split('/').length
306-
const query = await knex.raw('select * from storage.search_v2(?,?,?,?,?)', [
330+
const query = await knex.raw(`select * from storage.search_v2(${paramPlaceholders})`, [
307331
options?.prefix || '',
308332
bucketId,
309333
options?.maxKeys || 1000,
310334
levels,
311335
options?.startAfter || '',
336+
...sortParams,
312337
])
313338

314339
return query.rows

src/storage/object.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -586,18 +586,25 @@ export class ObjectStorage {
586586
startAfter?: string
587587
maxKeys?: number
588588
encodingType?: 'url'
589+
sortBy?: {
590+
column?: 'name' | 'created_at' | 'updated_at' | 'last_accessed_at'
591+
order?: string
592+
}
589593
}) {
590594
const limit = Math.min(options?.maxKeys || 1000, 1000)
591595
const prefix = options?.prefix || ''
592596
const delimiter = options?.delimiter
593597

594-
const cursor = options?.cursor ? decodeContinuationToken(options?.cursor) : undefined
598+
const cursor = options?.cursor
599+
? decodeContinuationToken(options?.sortBy?.column || 'name', options?.cursor)
600+
: undefined
595601
let searchResult = await this.db.listObjectsV2(this.bucketId, {
596602
prefix: options?.prefix,
597603
delimiter: options?.delimiter,
598604
maxKeys: limit + 1,
599605
nextToken: cursor,
600606
startAfter: cursor || options?.startAfter,
607+
sortBy: options?.sortBy,
601608
})
602609

603610
let prevPrefix = ''
@@ -644,9 +651,15 @@ export class ObjectStorage {
644651
})
645652
})
646653

647-
const nextContinuationToken = isTruncated
648-
? encodeContinuationToken(searchResult[searchResult.length - 1].name)
649-
: undefined
654+
let nextContinuationToken: string | undefined
655+
if (isTruncated) {
656+
const sortColumn = options?.sortBy?.column || 'name'
657+
const tokenValue =
658+
sortColumn === 'name'
659+
? searchResult[searchResult.length - 1].name
660+
: new Date(searchResult[searchResult.length - 1][sortColumn] || '').toISOString()
661+
nextContinuationToken = encodeContinuationToken(sortColumn, tokenValue)
662+
}
650663

651664
return {
652665
hasNext: isTruncated,
@@ -806,16 +819,21 @@ export class ObjectStorage {
806819
}
807820
}
808821

809-
function encodeContinuationToken(name: string) {
810-
return Buffer.from(`l:${name}`).toString('base64')
822+
function encodeContinuationToken(sortKey: string, value: string) {
823+
return Buffer.from(`${sortKey}:${value}`).toString('base64')
811824
}
812825

813-
function decodeContinuationToken(token: string) {
826+
function decodeContinuationToken(sortKey: string, token: string) {
814827
const decoded = Buffer.from(token, 'base64').toString().split(':')
815828

816829
if (decoded.length === 0) {
817830
throw new Error('Invalid continuation token')
818831
}
819832

820-
return decoded[1]
833+
const tokenSort = decoded.shift()
834+
if (tokenSort !== sortKey) {
835+
throw new Error('Invalid continuation token. Sort column mismatch')
836+
}
837+
838+
return decoded.join(':')
821839
}

0 commit comments

Comments
 (0)