draft(catalog-backend): drive queryEntities ordering from search-by-key

Replace the LEFT OUTER JOIN + DISTINCT in the queryEntities CTE with
an INNER JOIN that drives from the search table for the sort field's
key. Entities lacking the sort field are excluded from both the result
and the count, aligning totalItems with navigable entities.

Removes DISTINCT (prerequisite: search table dedup migration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
This commit is contained in:
Fredrik Adelöw
2026-05-09 22:45:43 +02:00
committed by Fredrik Adelöw
parent 3be2ee90b6
commit c2de113030
3 changed files with 48 additions and 34 deletions
@@ -1899,27 +1899,28 @@ describe('DefaultEntitiesCatalog', () => {
stitcher,
});
await expect(
catalog
.queryEntities({
orderFields: [{ field: 'metadata.title', order: 'asc' }],
credentials: mockCredentials.none(),
})
.then(r =>
entitiesResponseToObjects(r.items).map(e => e!.metadata.name),
),
).resolves.toEqual(['CC', 'BB', 'AA']); // 'AA' has no title, ends up last
// Entities without the sort field are excluded — sorting by a field
// means "show me entities that have this field, in order." The count
// also reflects only the entities that will be returned.
const ascResult = await catalog.queryEntities({
orderFields: [{ field: 'metadata.title', order: 'asc' }],
credentials: mockCredentials.none(),
});
expect(
entitiesResponseToObjects(ascResult.items).map(e => e!.metadata.name),
).toEqual(['CC', 'BB']);
expect(ascResult.totalItems).toBe(2);
await expect(
catalog
.queryEntities({
orderFields: [{ field: 'metadata.title', order: 'desc' }],
credentials: mockCredentials.none(),
})
.then(r =>
entitiesResponseToObjects(r.items).map(e => e!.metadata.name),
),
).resolves.toEqual(['BB', 'CC', 'AA']); // 'AA' has no title, ends up last
const descResult = await catalog.queryEntities({
orderFields: [{ field: 'metadata.title', order: 'desc' }],
credentials: mockCredentials.none(),
});
expect(
entitiesResponseToObjects(descResult.items).map(
e => e!.metadata.name,
),
).toEqual(['BB', 'CC']);
expect(descResult.totalItems).toBe(2);
},
);
@@ -273,33 +273,39 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
const sortField = cursor.orderFields.at(0);
// The first part of the query builder is a subquery that applies all of the
// filtering.
// filtering. When a sort field is specified, the search table for that key
// drives the query via INNER JOIN so that the (key, value, entity_id)
// index walks rows in sort order, letting LIMIT short-circuit. Entities
// that lack the sort field are excluded from both the result set and the
// count — this is a deliberate choice that aligns totalItems with the
// number of entities actually reachable through cursor pagination.
const dbQuery = this.database.with(
'filtered',
['entity_id', 'final_entity', ...(sortField ? ['value'] : [])],
inner => {
inner
.from<DbFinalEntitiesRow>('final_entities')
.whereNotNull('final_entity');
if (sortField) {
inner
.distinct()
.leftOuterJoin('search', qb =>
qb
.on('search.entity_id', 'final_entities.entity_id')
.andOnVal('search.key', sortField.field),
.from('search')
.innerJoin(
'final_entities',
'final_entities.entity_id',
'search.entity_id',
)
.where('search.key', sortField.field)
.whereNotNull('final_entities.final_entity')
.select({
entity_id: 'final_entities.entity_id',
final_entity: 'final_entities.final_entity',
value: 'search.value',
});
} else {
inner.select({
entity_id: 'final_entities.entity_id',
final_entity: 'final_entities.final_entity',
});
inner
.from<DbFinalEntitiesRow>('final_entities')
.whereNotNull('final_entity')
.select({
entity_id: 'final_entities.entity_id',
final_entity: 'final_entities.final_entity',
});
}
// Add regular filters and/or predicate query, if given