Spaces:
Running
Running
File size: 9,886 Bytes
aa89cb5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | import { FacetRequest, SelectQuery } from './ast';
import { SmartList, SmartListRecord } from './smart-list';
import { QueryRequest } from './request-intent';
import { retainQueryDiagnosticOrigin } from './query-snapshot';
export interface FacetAggregate {
function: string;
field: string;
alias: string;
}
export interface FacetQueryService {
executeQuery(query: SelectQuery): Promise<SmartListRecord[]>;
executeFacetAggregates?(
outerQuery: SelectQuery,
relationName: string,
aggregates: readonly FacetAggregate[],
): Promise<Map<string, SmartListRecord>>;
executeFacetMembership?(
outerQuery: SelectQuery, relationName: string, countAliases?: readonly string[],
): Promise<Map<string, number>>;
}
function snakeCase(value: string): string {
return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
}
function scalarId(value: unknown): unknown {
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
return record.id ?? record.Id;
}
return value;
}
function relationId(row: SmartListRecord, relationName: string): unknown {
const snake = snakeCase(relationName);
for (const key of [relationName, `${relationName}Id`, snake, `${snake}_id`]) {
const value = scalarId(row[key]);
if (value !== undefined && value !== null) return value;
}
return undefined;
}
function emptyAggregateValue(aggregate: FacetAggregate): unknown {
const fn = aggregate.function.toLowerCase();
return fn === 'count' || fn === 'sum' ? 0 : null;
}
function compareAggregateValues(left: unknown, right: unknown): number {
if (typeof left === 'number' && typeof right === 'number') return left - right;
if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime();
return String(left).localeCompare(String(right));
}
async function executeFacetAggregatesFallback(
service: FacetQueryService,
prepareQuery: (query: SelectQuery) => SelectQuery,
request: QueryRequest,
outerQuery: SelectQuery,
relationName: string,
aggregates: readonly FacetAggregate[],
): Promise<Map<string, SmartListRecord>> {
const membershipQuery = outerQuery.clone();
membershipQuery.facets = [];
membershipQuery.relations = [];
membershipQuery.relationAggregates = [];
membershipQuery.orderItems = [];
membershipQuery.aggregateItems = [];
membershipQuery.groupByItems = [];
membershipQuery.offsetValue = 0;
membershipQuery.limitValue = 0;
membershipQuery.selectItems = [...new Set([
relationName,
...aggregates.map(aggregate => aggregate.field),
])];
membershipQuery.comment(`aggregate ${relationName}`);
retainQueryDiagnosticOrigin(outerQuery, membershipQuery);
const memberships = await service.executeQuery(
request.deriveHelper(prepareQuery(membershipQuery), `aggregate ${relationName}`).query,
);
const result = new Map<string, SmartListRecord>();
const averageCounts = new Map<string, Record<string, number>>();
for (const row of memberships) {
const id = relationId(row, relationName);
if (id === undefined || id === null) continue;
const key = String(id);
const values = result.get(key) ?? {};
const counts = averageCounts.get(key) ?? {};
for (const aggregate of aggregates) {
const fn = aggregate.function.toLowerCase();
const raw = row[aggregate.field];
if (fn === 'count') {
if (raw !== undefined && raw !== null) {
values[aggregate.alias] = Number(values[aggregate.alias] ?? 0) + 1;
}
} else if (fn === 'sum' || fn === 'avg') {
if (raw !== undefined && raw !== null) {
const numeric = Number(raw);
if (!Number.isFinite(numeric)) {
throw new Error(`Facet aggregate ${aggregate.alias} requires numeric values`);
}
values[aggregate.alias] = Number(values[aggregate.alias] ?? 0) + numeric;
if (fn === 'avg') counts[aggregate.alias] = (counts[aggregate.alias] ?? 0) + 1;
}
} else if (fn === 'min' || fn === 'max') {
if (raw !== undefined && raw !== null) {
const current = values[aggregate.alias];
const comparison = current === undefined || current === null
? 0
: compareAggregateValues(raw, current);
if (current === undefined || current === null
|| (fn === 'min' && comparison < 0)
|| (fn === 'max' && comparison > 0)) {
values[aggregate.alias] = raw;
}
}
} else {
throw new Error(`Unsupported facet aggregate: ${aggregate.function}`);
}
}
result.set(key, values);
averageCounts.set(key, counts);
}
for (const [key, values] of result) {
const counts = averageCounts.get(key) ?? {};
for (const aggregate of aggregates) {
if (aggregate.function.toLowerCase() !== 'avg') continue;
const count = counts[aggregate.alias] ?? 0;
values[aggregate.alias] = count ? Number(values[aggregate.alias] ?? 0) / count : null;
}
}
return result;
}
/**
* Execute relation facets without crossing entity boundaries with the outer
* filter. The outer query determines relation membership; the nested query
* determines which facet entities and fields are returned.
*/
export async function executeRelationFacets(
service: FacetQueryService,
prepareQuery: (query: SelectQuery) => SelectQuery,
outerQuery: SelectQuery,
facets: readonly FacetRequest[],
): Promise<Record<string, SmartList<SmartListRecord>>> {
const request = new QueryRequest(outerQuery);
outerQuery = request.query;
const result: Record<string, SmartList<SmartListRecord>> = {};
for (const facet of facets) {
const operation = request.deriveOperation(outerQuery, facet.operation ?? {
kind: 'facet', relation: facet.relationName, alias: facet.facetName,
});
const nestedRequest = operation.deriveChild(facet.query.clone(), facet.relationName);
const nestedQuery = nestedRequest.query;
const nestedFacets = nestedQuery.facets;
nestedQuery.facets = [];
const requestedAggregates: FacetAggregate[] = nestedQuery.aggregateItems.map(item => ({
function: String(item.function),
field: String(item.field),
alias: String(item.alias),
}));
const executedAggregates = requestedAggregates.length
? requestedAggregates
: [{ function: 'Count', field: 'id', alias: '__teaql_facet_membership_count' }];
let aggregateValues: Map<string, SmartListRecord>;
if (service.executeFacetAggregates) {
aggregateValues = await service.executeFacetAggregates(
operation.deriveHelper(
prepareQuery(outerQuery.clone()), `aggregate ${facet.relationName}`,
).query,
facet.relationName,
executedAggregates,
);
} else if (service.executeFacetMembership
&& executedAggregates.every(item => item.function.toLowerCase() === 'count')) {
const counts = await service.executeFacetMembership(
operation.deriveHelper(
prepareQuery(outerQuery.clone()), `aggregate ${facet.relationName}`,
).query,
facet.relationName,
);
aggregateValues = new Map([...counts].map(([key, count]) => [
key,
Object.fromEntries(executedAggregates.map(item => [item.alias, count])),
]));
} else {
aggregateValues = await executeFacetAggregatesFallback(
service, prepareQuery, operation, outerQuery, facet.relationName, executedAggregates,
);
}
nestedQuery.aggregateItems = [];
nestedQuery.groupByItems = [];
for (const aggregate of nestedQuery.relationAggregates) {
if (aggregate.query.entity !== outerQuery.entity) continue;
// Reverse facet sums share the complete source membership, not the
// returned page or all source rows pointing at the same facet value.
const membership = outerQuery.clone();
membership.selectItems = ['id'];
membership.properties = [];
membership.relations = [];
membership.relationAggregates = [];
membership.facets = [];
membership.aggregateItems = [];
membership.groupByItems = [];
membership.orderItems = [];
membership.offsetValue = 0;
membership.limitValue = 0;
const filter = { id: { $inSubquery: { query: membership, field: 'id' } } };
aggregate.query.filterCondition = aggregate.query.filterCondition
? { $and: [aggregate.query.filterCondition, filter] } : filter;
retainQueryDiagnosticOrigin(outerQuery, aggregate.query);
}
// Restrict before pagination and before evaluating child facets. Filtering
// only the returned rows makes child counts include unrelated entities.
if (!facet.includeAllFacets) {
const membership = { id: { $in: [...aggregateValues.keys()] } };
nestedQuery.filterCondition = nestedQuery.filterCondition
? { $and: [nestedQuery.filterCondition, membership] } : membership;
}
retainQueryDiagnosticOrigin(outerQuery, nestedQuery);
const rows = await service.executeQuery(nestedRequest.withQuery(prepareQuery(nestedQuery)).query);
const decorated = rows
.map(row => {
const key = String(scalarId(row.id ?? row.Id));
const values = aggregateValues.get(key);
const copy = { ...row };
for (const aggregate of requestedAggregates) {
copy[aggregate.alias] = values?.[aggregate.alias] ?? emptyAggregateValue(aggregate);
}
return copy;
})
.filter(row => facet.includeAllFacets
|| aggregateValues.has(String(scalarId(row.id ?? row.Id))));
const list = new SmartList(decorated);
if (nestedFacets.length) {
list.facets = await executeRelationFacets(service, prepareQuery,
nestedRequest.withQuery(nestedQuery).query, nestedFacets);
}
result[facet.facetName] = list;
}
return result;
}
|