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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,14 +251,17 @@ const example = query({
ctx,
{ latitude: 40.7813, longitude: -73.9737 },
maxResults,
maxDistance,
{
maxDistance,
filter: (q) => q.eq("category", "coffee"),
},
);
return result;
},
});
```

The `maxDistance` parameter is optional, but providing it can greatly speed up searching the index.
The fourth argument can either be a numeric `maxDistance` (for backwards compatibility) or an options object. When you pass an options object you can combine `maxDistance` with the same filter builder used by `query`, including `eq`, `in`, `gte`, and `lt` conditions. Filtering helps constrain the search space and can speed up lookups.

## Example

Expand Down
8 changes: 8 additions & 0 deletions example/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,14 @@ export declare const components: {
minLevel: number;
nextCursor?: string;
point: { latitude: number; longitude: number };
filtering: Array<{
filterKey: string;
filterValue: string | number | boolean | null | bigint;
occur: "should" | "must";
}>;
sorting: {
interval: { endExclusive?: number; startInclusive?: number };
};
},
Array<{
coordinates: { latitude: number; longitude: number };
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
"scripts": {
"build": "npm run build:esm && npm run build:cjs",
"build:go": "cd src/s2-bindings && python build.py",
"build:esm": "tsc --project ./esm.json && echo '{\\n \"type\": \"module\"\\n}' > dist/esm/package.json",
"build:cjs": "tsc --project ./commonjs.json && echo '{\\n \"type\": \"commonjs\"\\n}' > dist/commonjs/package.json",
"build:esm": "tsc --project ./esm.json && node -e \"require('fs').writeFileSync('dist/esm/package.json', JSON.stringify({type:'module'}, null, 2) + '\\n')\"",
"build:cjs": "tsc --project ./commonjs.json && node -e \"require('fs').writeFileSync('dist/commonjs/package.json', JSON.stringify({type:'commonjs'}, null, 2) + '\\n')\"",
"dev": "cd example; npm run dev",
"typecheck": "tsc --noEmit",
"prepare": "npm run build",
Expand Down Expand Up @@ -94,4 +94,4 @@
"main": "./dist/commonjs/client/index.js",
"types": "./dist/commonjs/client/index.d.ts",
"module": "./dist/esm/client/index.js"
}
}
40 changes: 37 additions & 3 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export type GeospatialDocument<
sortKey: number;
};

export type QueryNearestOptions<
Doc extends GeospatialDocument = GeospatialDocument,
> = {
maxDistance?: number;
filter?: NonNullable<GeospatialQuery<Doc>["filter"]>;
};

export interface GeospatialIndexOptions {
/**
* The minimum S2 cell level to use when querying. Defaults to 4.
Expand Down Expand Up @@ -211,23 +218,50 @@ export class GeospatialIndex<
* @param ctx - The Convex query context.
* @param point - The point to query for.
* @param maxResults - The maximum number of results to return.
* @param maxDistance - The maximum distance to return results within in meters.
* @param maxDistanceOrOptions - Either the maximum distance in meters or an options object containing filtering logic.
* @param maybeOptions - Additional options when the maximum distance is provided separately.
* @returns - An array of objects with the key-coordinate pairs and their distance from the query point in meters.
*/
async queryNearest(
ctx: QueryCtx,
point: Point,
maxResults: number,
maxDistance?: number,
maxDistanceOrOptions?:
| number
| QueryNearestOptions<GeospatialDocument<Key, Filters>>,
maybeOptions?: QueryNearestOptions<GeospatialDocument<Key, Filters>>,
) {
let options:
| QueryNearestOptions<GeospatialDocument<Key, Filters>>
| undefined;
let maxDistance: number | undefined;
if (
typeof maxDistanceOrOptions === "object" &&
maxDistanceOrOptions !== null
) {
options = maxDistanceOrOptions;
} else {
maxDistance = maxDistanceOrOptions;
options = maybeOptions;
}

const filterBuilder = new FilterBuilderImpl<
GeospatialDocument<Key, Filters>
>();
if (options?.filter) {
options.filter(filterBuilder);
}

const resp = await ctx.runQuery(this.component.query.nearestPoints, {
point,
maxDistance,
maxDistance: options?.maxDistance ?? maxDistance,
maxResults,
minLevel: this.minLevel,
maxLevel: this.maxLevel,
levelMod: this.levelMod,
logLevel: this.logLevel,
filtering: filterBuilder.filterConditions,
sorting: { interval: filterBuilder.interval ?? {} },
});
return resp as { key: Key; coordinates: Point; distance: number }[];
}
Expand Down
8 changes: 8 additions & 0 deletions src/component/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ export type Mounts = {
minLevel: number;
nextCursor?: string;
point: { latitude: number; longitude: number };
filtering: Array<{
filterKey: string;
filterValue: string | number | boolean | null | bigint;
occur: "should" | "must";
}>;
sorting: {
interval: { endExclusive?: number; startInclusive?: number };
};
},
Array<{
coordinates: { latitude: number; longitude: number };
Expand Down
74 changes: 72 additions & 2 deletions src/component/lib/pointQuery.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { Heap } from "heap-js";
import type { Primitive } from "../types.js";
import { ChordAngle, Meters, Point } from "../types.js";
import { Id } from "../_generated/dataModel.js";
import { Doc, Id } from "../_generated/dataModel.js";
import { S2Bindings } from "./s2Bindings.js";
import { QueryCtx } from "../_generated/server.js";
import * as approximateCounter from "./approximateCounter.js";
import { cellCounterKey } from "../streams/cellRange.js";
import { decodeTupleKey } from "./tupleKey.js";
import { Logger } from "./logging.js";
import type { Interval } from "./interval.js";

type FilterCondition = {
filterKey: string;
filterValue: Primitive;
occur: "must" | "should";
};

export class ClosestPointQuery {
// Min-heap of cells to process.
Expand All @@ -16,6 +24,9 @@ export class ClosestPointQuery {
results: Heap<Result>;

maxDistanceChordAngle?: ChordAngle;
private mustFilters: FilterCondition[];
private shouldFilters: FilterCondition[];
private sortInterval: Interval;

constructor(
private s2: S2Bindings,
Expand All @@ -26,11 +37,18 @@ export class ClosestPointQuery {
private minLevel: number,
private maxLevel: number,
private levelMod: number,
filtering: FilterCondition[] = [],
interval: Interval = {},
) {
this.toProcess = new Heap<CellCandidate>((a, b) => a.distance - b.distance);
this.results = new Heap<Result>((a, b) => b.distance - a.distance);
this.maxDistanceChordAngle =
this.maxDistance && this.s2.metersToChordAngle(this.maxDistance);
this.mustFilters = filtering.filter((filter) => filter.occur === "must");
this.shouldFilters = filtering.filter(
(filter) => filter.occur === "should",
);
this.sortInterval = interval;

for (const cellID of this.s2.initialCells(this.minLevel)) {
const distance = this.s2.minDistanceToCell(this.point, cellID);
Expand Down Expand Up @@ -85,7 +103,9 @@ export class ClosestPointQuery {
if (!point) {
throw new Error("Point not found");
}
this.addResult(point._id, point.coordinates);
if (this.matchesFilters(point)) {
this.addResult(point._id, point.coordinates);
}
}
}
}
Expand All @@ -99,6 +119,9 @@ export class ClosestPointQuery {
if (!point) {
throw new Error("Point not found");
}
if (!this.matchesFilters(point)) {
continue;
}
results.push({
key: point.key,
coordinates: point.coordinates,
Expand All @@ -108,6 +131,53 @@ export class ClosestPointQuery {
return results;
}

private matchesFilters(point: Doc<"points">): boolean {
if (
this.sortInterval.startInclusive !== undefined &&
point.sortKey < this.sortInterval.startInclusive
) {
return false;
}
if (
this.sortInterval.endExclusive !== undefined &&
point.sortKey >= this.sortInterval.endExclusive
) {
return false;
}

for (const filter of this.mustFilters) {
if (!this.pointMatchesCondition(point, filter)) {
return false;
}
}

if (this.shouldFilters.length > 0) {
let anyMatch = false;
for (const filter of this.shouldFilters) {
if (this.pointMatchesCondition(point, filter)) {
anyMatch = true;
break;
}
}
if (!anyMatch) {
return false;
}
}

return true;
}

private pointMatchesCondition(point: Doc<"points">, filter: FilterCondition) {
const value = point.filterKeys[filter.filterKey];
if (value === undefined) {
return false;
}
if (Array.isArray(value)) {
return value.some((candidate) => candidate === filter.filterValue);
}
return value === filter.filterValue;
}

addCandidate(cellID: bigint, level: number, distance: ChordAngle) {
if (this.maxDistanceChordAngle && distance > this.maxDistanceChordAngle) {
return;
Expand Down
6 changes: 6 additions & 0 deletions src/component/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ export const nearestPoints = query({
maxLevel: v.number(),
levelMod: v.number(),
nextCursor: v.optional(v.string()),
filtering: v.array(equalityCondition),
sorting: v.object({
interval,
}),
logLevel,
},
returns: v.array(queryResultWithDistance),
Expand All @@ -292,6 +296,8 @@ export const nearestPoints = query({
args.minLevel,
args.maxLevel,
args.levelMod,
args.filtering,
args.sorting.interval,
);
const results = await query.execute(ctx);
return results;
Expand Down
67 changes: 64 additions & 3 deletions src/component/tests/pointQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@ test("closest point query - basic functionality", async () => {
key: "point1",
coordinates: { latitude: 0, longitude: 0 },
sortKey: 1,
filterKeys: {},
filterKeys: { category: "coffee" },
},
{
key: "point2",
coordinates: { latitude: 1, longitude: 1 },
sortKey: 2,
filterKeys: {},
filterKeys: { category: "tea" },
},
{
key: "point3",
coordinates: { latitude: -1, longitude: -1 },
sortKey: 3,
filterKeys: {},
filterKeys: { category: "coffee" },
},
];

Expand Down Expand Up @@ -99,6 +99,67 @@ test("closest point query - basic functionality", async () => {
const result3 = await query3.execute(ctx);
expect(result3.length).toBe(1);
expect(result3[0].key).toBe("point1");

// Test must filter
const query4 = new ClosestPointQuery(
s2,
logger,
{ latitude: 0, longitude: 0 },
10000000,
3,
opts.minLevel,
opts.maxLevel,
opts.levelMod,
[
{
occur: "must",
filterKey: "category",
filterValue: "coffee",
},
],
);
const result4 = await query4.execute(ctx);
expect(result4.length).toBe(2);
expect(result4.map((r) => r.key).sort()).toEqual(["point1", "point3"]);

// Test should filter (must match at least one)
const query5 = new ClosestPointQuery(
s2,
logger,
{ latitude: 0, longitude: 0 },
10000000,
3,
opts.minLevel,
opts.maxLevel,
opts.levelMod,
[
{
occur: "should",
filterKey: "category",
filterValue: "tea",
},
],
);
const result5 = await query5.execute(ctx);
expect(result5.length).toBe(1);
expect(result5[0].key).toBe("point2");

// Test sort key interval
const query6 = new ClosestPointQuery(
s2,
logger,
{ latitude: 0, longitude: 0 },
10000000,
3,
opts.minLevel,
opts.maxLevel,
opts.levelMod,
[],
{ startInclusive: 3 },
);
const result6 = await query6.execute(ctx);
expect(result6.length).toBe(1);
expect(result6[0].key).toBe("point3");
});
});

Expand Down