Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions orga/changelog/fix-query-planner-out-of-range-bounds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- FIX query planner: a `$gt`/`$lt` range bound that lies entirely outside the indexed field's schema `minimum`/`maximum` no longer silently drops the boundary document. The out-of-range bound was clamped to the boundary when building the index string but kept exclusive, so e.g. `$gt: -10` on a field with `minimum: 0` excluded the document with value `0` (and `$lt: 110` with `maximum: 100` excluded `100`). Such a bound is now treated as an inclusive open end.
28 changes: 28 additions & 0 deletions src/query-planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,34 @@ export function getQueryPlan<RxDocType>(
matcherOpts.inclusiveEnd = true;
}

/**
* A range bound that lies entirely outside the field's schema
* [minimum, maximum] range matches every in-range value on that side.
* Without this, an out-of-range exclusive bound (e.g. $gt below the
* minimum or $lt above the maximum) is clamped to the boundary when the
* index string is built but kept exclusive, silently dropping the
* boundary document.
*/
const indexFieldSchema = getSchemaByObjectPath(schema, indexField);
if (
indexFieldSchema &&
typeof indexFieldSchema.minimum === 'number' &&
typeof matcherOpts.startKey === 'number' &&
matcherOpts.startKey < indexFieldSchema.minimum
) {
matcherOpts.startKey = INDEX_MIN;
matcherOpts.inclusiveStart = true;
}
if (
indexFieldSchema &&
typeof indexFieldSchema.maximum === 'number' &&
typeof matcherOpts.endKey === 'number' &&
matcherOpts.endKey > indexFieldSchema.maximum
) {
matcherOpts.endKey = INDEX_MAX;
matcherOpts.inclusiveEnd = true;
}

if (inclusiveStart && !matcherOpts.inclusiveStart) {
inclusiveStart = false;
}
Expand Down
76 changes: 76 additions & 0 deletions test/unit/query-planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,82 @@ describeParallel('query-planner.test.js', () => {
assert.strictEqual(lastOfArray(queryPlan.startKeys), INDEX_MAX);
assert.strictEqual(lastOfArray(queryPlan.endKeys), INDEX_MIN);
});
it('should turn out-of-schema number bounds into inclusive open ends', () => {
type ScoreDoc = {
id: string;
score: number;
};
const schema: RxJsonSchema<RxDocumentData<ScoreDoc>> = fillWithDefaultSettings({
version: 0,
primaryKey: 'id',
type: 'object',
indexes: [['score']],
properties: {
id: { type: 'string', maxLength: 10 },
score: {
type: 'number',
minimum: 0,
maximum: 100,
multipleOf: 1
}
},
required: ['id', 'score']
});

const belowMinimumPlan = getQueryPlan(
schema,
normalizeMangoQuery<RxDocumentData<ScoreDoc>>(
schema,
{
selector: { score: { $gt: -10 } },
index: ['score']
}
)
);
assert.deepStrictEqual(belowMinimumPlan.index, ['score', 'id']);
assert.strictEqual(belowMinimumPlan.startKeys[0], INDEX_MIN);
assert.strictEqual(belowMinimumPlan.inclusiveStart, true);

const aboveMaximumPlan = getQueryPlan(
schema,
normalizeMangoQuery<RxDocumentData<ScoreDoc>>(
schema,
{
selector: { score: { $lt: 110 } },
index: ['score']
}
)
);
assert.deepStrictEqual(aboveMaximumPlan.index, ['score', 'id']);
assert.strictEqual(aboveMaximumPlan.endKeys[0], INDEX_MAX);
assert.strictEqual(aboveMaximumPlan.inclusiveEnd, true);

const exactMinimumPlan = getQueryPlan(
schema,
normalizeMangoQuery<RxDocumentData<ScoreDoc>>(
schema,
{
selector: { score: { $gt: 0 } },
index: ['score']
}
)
);
assert.strictEqual(exactMinimumPlan.startKeys[0], 0);
assert.strictEqual(exactMinimumPlan.inclusiveStart, false);

const exactMaximumPlan = getQueryPlan(
schema,
normalizeMangoQuery<RxDocumentData<ScoreDoc>>(
schema,
{
selector: { score: { $lt: 100 } },
index: ['score']
}
)
);
assert.strictEqual(exactMaximumPlan.endKeys[0], 100);
assert.strictEqual(exactMaximumPlan.inclusiveEnd, false);
});
});
describe('.isSelectorSatisfiedByIndex()', () => {
const schema = getHumanSchemaWithIndexes([['age']]);
Expand Down
43 changes: 43 additions & 0 deletions test/unit/rx-storage-query-correctness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,49 @@ describeParallel('rx-storage-query-correctness.test.ts', () => {
},
],
});
testCorrectQueries<{ id: string; score: number; }>({
testTitle: 'range bound outside the schema minimum/maximum must still match the boundary documents',
data: [
{ id: 'aa', score: 0 },
{ id: 'bb', score: 1 },
{ id: 'cc', score: 50 },
{ id: 'dd', score: 99 },
{ id: 'ee', score: 100 }
],
schema: {
version: 0,
indexes: [['score']],
primaryKey: 'id',
type: 'object',
properties: {
id: { type: 'string', maxLength: 2 },
score: { type: 'number', minimum: 0, maximum: 100, multipleOf: 1 }
},
required: ['id', 'score']
},
queries: [
{
info: '$gt below the schema minimum must include the minimum document',
query: { selector: { score: { $gt: -10 } }, sort: [{ score: 'asc' }] },
expectedResultDocIds: ['aa', 'bb', 'cc', 'dd', 'ee']
},
{
info: '$lt above the schema maximum must include the maximum document',
query: { selector: { score: { $lt: 110 } }, sort: [{ score: 'asc' }] },
expectedResultDocIds: ['aa', 'bb', 'cc', 'dd', 'ee']
},
{
info: '$gt at the exact minimum must still exclude it',
query: { selector: { score: { $gt: 0 } }, sort: [{ score: 'asc' }] },
expectedResultDocIds: ['bb', 'cc', 'dd', 'ee']
},
{
info: '$lt at the exact maximum must still exclude it',
query: { selector: { score: { $lt: 100 } }, sort: [{ score: 'asc' }] },
expectedResultDocIds: ['aa', 'bb', 'cc', 'dd']
}
]
});
/**
* @link https://github.com/pubkey/rxdb/issues/5273
*/
Expand Down
Loading