@azure/cosmos
Version:
Microsoft Azure Cosmos DB Service Node.js SDK for NOSQL API
310 lines • 15.5 kB
JavaScript
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
/**
* Strategy for filtering partition ranges in ORDER BY query execution context
* Supports resuming from continuation tokens with proper range-token pair management
* @hidden
*/
export class OrderByQueryRangeStrategy {
getStrategyType() {
return "OrderByQuery";
}
filterPartitionRanges(targetRanges, continuationRanges, queryInfo) {
if (!targetRanges ||
targetRanges.length === 0 ||
!continuationRanges ||
continuationRanges.length === 0) {
return {
rangeTokenPairs: [],
};
}
if (!queryInfo?.orderByItems ||
!Array.isArray(queryInfo.orderByItems) ||
queryInfo.orderByItems.length === 0) {
throw new Error("Unable to resume ORDER BY query from continuation token. orderByItems is required for ORDER BY queries.");
}
const result = {
rangeTokenPairs: [],
};
let filteredRanges = [];
let resumeRangeFound = false;
if (continuationRanges && continuationRanges.length > 0) {
resumeRangeFound = true;
// Find the range to resume from based on the composite token
const targetRangeMapping = continuationRanges[continuationRanges.length - 1].range;
// It is assumed that range mapping array is going to contain only range
const targetRange = targetRangeMapping;
const targetContinuationToken = continuationRanges[continuationRanges.length - 1].continuationToken;
const leftRanges = targetRanges.filter((mapping) => this.isRangeBeforeAnother(mapping.maxExclusive, targetRangeMapping.minInclusive));
const orderByItems = queryInfo.orderByItems;
// Create filtering condition for left ranges based on ORDER BY items and sort orders
const leftFilter = this.createRangeFilterCondition(orderByItems, queryInfo, "left");
const rightRanges = targetRanges.filter((mapping) => this.isRangeAfterAnother(mapping.minInclusive, targetRangeMapping.maxExclusive));
// Create filtering condition for right ranges based on ORDER BY items and sort orders
const rightFilter = this.createRangeFilterCondition(orderByItems, queryInfo, "right");
// Apply filtering logic for left ranges
if (leftRanges.length > 0) {
leftRanges.forEach((range) => {
result.rangeTokenPairs.push({
range: range,
continuationToken: undefined,
filteringCondition: leftFilter,
});
});
}
result.rangeTokenPairs.push({
range: targetRange,
continuationToken: targetContinuationToken,
filteringCondition: rightFilter,
});
// Apply filtering logic for right ranges
if (rightRanges.length > 0) {
rightRanges.forEach((range) => {
result.rangeTokenPairs.push({
range: range,
continuationToken: undefined,
filteringCondition: rightFilter,
});
});
}
}
// If we couldn't find a specific resume point, include all ranges
// This can happen with certain types of ORDER BY continuation tokens
if (!resumeRangeFound) {
filteredRanges = [...targetRanges];
filteredRanges.forEach((range) => {
result.rangeTokenPairs.push({
range: range,
continuationToken: undefined,
filteringCondition: undefined,
});
});
}
return result;
}
/**
* Creates a filter condition for ranges based on ORDER BY items and sort orders
* This filter ensures that ranges only return documents based on their position relative to the continuation point
* @param orderByItems - Array of order by items from the continuation token
* @param queryInfo - Query information containing sort orders and other metadata
* @param rangePosition - Whether this is for "left" or "right" ranges relative to continuation point
* @returns SQL filter condition string for the specified range position
*/
createRangeFilterCondition(orderByItems, queryInfo, rangePosition) {
// Extract sort orders from query info
let sortOrders;
try {
sortOrders = this.extractSortOrders(queryInfo);
}
catch (error) {
// If we can't extract sort orders, we cannot create reliable filter conditions
throw new Error(`Unable to resume ORDER BY query from continuation token. The ORDER BY sort direction configuration ` +
`in the query plan is invalid or missing. This may indicate a client version mismatch or corrupted continuation token. ` +
`Please retry the query without a continuation token. Original error: ${error}`);
}
// Extract orderByExpressions from nested structure
let orderByExpressions;
if (queryInfo &&
queryInfo.queryInfo &&
typeof queryInfo.queryInfo === "object" &&
queryInfo.queryInfo.queryInfo &&
queryInfo.queryInfo.queryInfo.orderByExpressions &&
Array.isArray(queryInfo.queryInfo.queryInfo.orderByExpressions)) {
orderByExpressions = queryInfo.queryInfo.queryInfo.orderByExpressions;
}
if (!orderByExpressions || !Array.isArray(orderByExpressions)) {
throw new Error("Unable to resume ORDER BY query from continuation token. The ORDER BY field configuration " +
"in the query plan is invalid or missing. This may indicate a client version mismatch or corrupted continuation token. " +
"Please retry the query without a continuation token.");
}
const filterConditions = [];
// Process each order by item to create filter conditions
for (let i = 0; i < orderByItems.length && i < sortOrders.length; i++) {
const orderByItem = orderByItems[i];
const sortOrder = sortOrders[i];
if (!orderByItem || orderByItem.item === undefined) {
continue;
}
try {
// Determine the field path from ORDER BY expressions in query plan
const fieldPath = this.extractFieldPath(queryInfo, i);
// Create the comparison condition based on sort order and range position
const condition = this.createComparisonCondition(fieldPath, orderByItem.item, sortOrder, rangePosition);
if (condition) {
filterConditions.push(condition);
}
}
catch (error) {
// If we can't extract field path for ORDER BY expressions, we cannot safely resume from continuation token
// This would lead to incorrect query results, so we must fail the entire request
throw new Error(`Unable to resume ORDER BY query from continuation token. The ORDER BY field configuration ` +
`in the query plan is invalid or incompatible with the continuation token format. ` +
`This may indicate a client version mismatch or corrupted continuation token. ` +
`Please retry the query without a continuation token. Original error: ${error}`);
}
}
// Combine multiple conditions with AND for multi-field ORDER BY
const combinedFilter = filterConditions.length > 0 ? `(${filterConditions.join(" AND ")})` : "";
return combinedFilter;
}
/**
* Extracts sort orders from query info
* @throws Error if sort order information is missing or invalid
*/
extractSortOrders(queryInfo) {
if (!queryInfo) {
throw new Error("Query information is required to determine ORDER BY sort directions");
}
// Extract orderBy from the nested structure: queryInfo.queryInfo.queryInfo.orderBy
let orderBy;
if (queryInfo.queryInfo &&
typeof queryInfo.queryInfo === "object" &&
queryInfo.queryInfo.queryInfo &&
typeof queryInfo.queryInfo.queryInfo === "object" &&
queryInfo.queryInfo.queryInfo.orderBy &&
Array.isArray(queryInfo.queryInfo.queryInfo.orderBy)) {
orderBy = queryInfo.queryInfo.queryInfo.orderBy;
}
if (!orderBy) {
throw new Error("ORDER BY sort direction information is missing from query plan");
}
return orderBy.map((order, index) => {
if (typeof order === "string") {
return order;
}
// Handle object format if needed
if (order && typeof order === "object") {
const sortOrder = order.direction || order.order || order.sortOrder;
if (sortOrder) {
return sortOrder;
}
}
throw new Error(`ORDER BY sort direction at position ${index + 1} has an invalid format in the query plan`);
});
}
/**
* Extracts field path from ORDER BY expressions in query plan
* @throws Error if orderByExpressions are not found or index is out of bounds or expression format is invalid
*/
extractFieldPath(queryInfo, index) {
// Try multiple paths to find orderByExpressions due to nested structure
let orderByExpressions;
if (queryInfo) {
// Direct path
if (queryInfo.orderByExpressions && Array.isArray(queryInfo.orderByExpressions)) {
orderByExpressions = queryInfo.orderByExpressions;
}
// Nested path: queryInfo.queryInfo.queryInfo.orderByExpressions
else if (queryInfo.queryInfo &&
typeof queryInfo.queryInfo === "object" &&
queryInfo.queryInfo.queryInfo &&
queryInfo.queryInfo.queryInfo.orderByExpressions &&
Array.isArray(queryInfo.queryInfo.queryInfo.orderByExpressions)) {
orderByExpressions = queryInfo.queryInfo.queryInfo.orderByExpressions;
}
}
if (!orderByExpressions) {
throw new Error("ORDER BY field information is missing from query plan");
}
if (index >= orderByExpressions.length) {
throw new Error(`ORDER BY field configuration mismatch: expected at least ${index + 1} fields but found ${orderByExpressions.length}`);
}
const expression = orderByExpressions[index];
// Handle different formats of ORDER BY expressions
if (typeof expression === "string") {
// Simple string expression like "c.id" or "_FullTextScore(...)"
return expression;
}
if (expression && typeof expression === "object") {
// Object format like { expression: "c.id", type: "PropertyRef" }
if (expression.expression) {
return expression.expression;
}
if (expression.path) {
return expression.path.replace(/^\//, ""); // Remove leading slash
}
if (expression.field) {
return expression.field;
}
}
throw new Error(`ORDER BY field at position ${index + 1} has an unrecognized format in the query plan`);
}
/**
* Creates a comparison condition based on the field, value, sort order, and range position
*/
createComparisonCondition(fieldPath, value, sortOrder, rangePosition) {
const isDescending = sortOrder.toLowerCase() === "descending" || sortOrder.toLowerCase() === "desc";
// For left ranges (ranges that come before the target):
// - In ascending order: field > value (left ranges should seek for larger values)
// - In descending order: field < value (left ranges should seek for smaller values)
// For right ranges (ranges that come after the target):
// - In ascending order: field >= value (right ranges have larger values)
// - In descending order: field <= value (right ranges have smaller values in desc order)
let operator;
if (rangePosition === "left") {
operator = isDescending ? "<" : ">";
}
else {
// right
operator = isDescending ? "<=" : ">=";
}
// Format the value based on its type
const formattedValue = this.formatValueForSQL(value);
// Create the condition with proper field reference
const condition = `${fieldPath} ${operator} ${formattedValue}`;
return condition;
}
/**
* Formats a value for use in SQL condition
*/
formatValueForSQL(value) {
if (value === null || value === undefined) {
return "null";
}
const valueType = typeof value;
switch (valueType) {
case "string":
// Escape single quotes and wrap in quotes
return `'${value.toString().replace(/'/g, "''")}'`;
case "number":
case "bigint":
return value.toString();
case "boolean":
return value ? "true" : "false";
default:
// For objects and arrays, convert to JSON string
if (typeof value === "object") {
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
}
return `'${value.toString().replace(/'/g, "''")}'`;
}
}
/**
* Compares partition key range boundaries with proper handling for inclusive/exclusive semantics
* @param boundary1 - First boundary to compare
* @param boundary2 - Second boundary to compare
* @returns negative if boundary1 is less than boundary2, positive if boundary1 is greater than boundary2, 0 if equal
*/
comparePartitionKeyBoundaries(boundary1, boundary2) {
// Handle empty string cases (empty string represents the minimum boundary)
if (boundary1 === "" && boundary2 === "")
return 0;
if (boundary1 === "")
return -1; // "" < "AA"
if (boundary2 === "")
return 1; // "AA" > ""
// Use standard lexicographic comparison for non-empty boundaries
return boundary1.localeCompare(boundary2);
}
isRangeBeforeAnother(range1MaxExclusive, range2MinInclusive) {
// Since range1.maxExclusive is NOT part of range1, and range2.minInclusive IS part of range2,
// range1 comes before range2 if range1.maxExclusive <= range2.minInclusive
return this.comparePartitionKeyBoundaries(range1MaxExclusive, range2MinInclusive) <= 0;
}
isRangeAfterAnother(range1MinInclusive, range2MaxExclusive) {
// Since range2.maxExclusive is NOT part of range2, and range1.minInclusive IS part of range1,
// range1 comes after range2 if range1.minInclusive >= range2.maxExclusive
return this.comparePartitionKeyBoundaries(range1MinInclusive, range2MaxExclusive) >= 0;
}
}
//# sourceMappingURL=OrderByQueryRangeStrategy.js.map