@convex-dev/aggregate
Version:
Convex component to calculate counts and sums of values for efficient aggregation.
1,215 lines (1,057 loc) • 38 kB
text/typescript
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TableAggregate } from "./index.js";
import {
components,
componentSchema,
componentModules,
modules,
} from "./setup.test.js";
import {
defineSchema,
defineTable,
type GenericMutationCtx,
} from "convex/server";
import { v } from "convex/values";
import { convexTest } from "convex-test";
import type { DataModelFromSchemaDefinition } from "convex/server";
const schema = defineSchema({
testItems: defineTable({
name: v.string(),
value: v.number(),
}),
photos: defineTable({
album: v.string(),
url: v.string(),
score: v.number(),
}),
});
function setupTest() {
const t = convexTest(schema, modules);
t.registerComponent("aggregate", componentSchema, componentModules);
return t;
}
type ConvexTest = ReturnType<typeof setupTest>;
type DataModel = DataModelFromSchemaDefinition<typeof schema>;
// Helper function to create aggregates with fresh instances
// if we dont do this we will get strange errors if we share instances between tests
function createAggregates() {
const aggregate = new TableAggregate<{
Key: number;
DataModel: DataModel;
TableName: "testItems";
}>(components.aggregate, {
sortKey: (doc) => doc.value,
sumValue: (doc) => doc.value,
});
const aggregateWithNamespace = new TableAggregate<{
Namespace: string;
Key: number;
DataModel: DataModel;
TableName: "photos";
}>(components.aggregate, {
namespace: (doc) => doc.album,
sortKey: (doc) => doc.score, // Use score instead of _creationTime for predictable tests
});
return { aggregate, aggregateWithNamespace };
}
async function testItem(
ctx: GenericMutationCtx<DataModel>,
value: { name: string; value: number },
) {
const id = await ctx.db.insert("testItems", {
name: value.name,
value: value.value,
});
return (await ctx.db.get(id))!;
}
describe("TableAggregate", () => {
describe("count", () => {
let t: ConvexTest;
let aggregate: ReturnType<typeof createAggregates>["aggregate"];
beforeEach(() => {
t = setupTest();
({ aggregate } = createAggregates());
});
const exec = async () => {
return await t.run(async (ctx) => {
return await aggregate.count(ctx);
});
};
test("should count zero items in empty table", async () => {
const result = await exec();
expect(result).toBe(0);
});
test("should count two items after inserting two documents", async () => {
await t.run(async (ctx) => {
// Insert first document
const doc1 = await testItem(ctx, { name: "first", value: 10 });
await aggregate.insert(ctx, doc1);
// Insert second document
const doc2 = await testItem(ctx, { name: "second", value: 20 });
await aggregate.insert(ctx, doc2);
});
const result = await exec();
expect(result).toBe(2);
});
test("should paginate a single undefined namespace", async () => {
await t.run(async (ctx) => {
await aggregate.insert(
ctx,
await testItem(ctx, { name: "name", value: 1 }),
);
let count = 0;
for await (const namespace of aggregate.iterNamespaces(ctx)) {
expect(namespace).toBe(undefined);
count++;
}
expect(count).toBe(1);
});
});
});
describe("countBatch", () => {
let t: ConvexTest;
const { aggregate } = createAggregates();
beforeEach(() => {
t = setupTest();
});
test("should count zero items in empty table", async () => {
await t.run(async (ctx) => {
const result = await aggregate.countBatch(ctx, [
{ bounds: { lower: { key: 0, inclusive: true } } },
{ bounds: { lower: { key: 0, inclusive: false } } },
]);
expect(result.length).toBe(2);
expect(result[0]).toBe(0);
expect(result[1]).toBe(0);
const result2 = await aggregate.countBatch(ctx, [{}, {}]);
expect(result2.length).toBe(2);
expect(result2[0]).toBe(0);
expect(result2[1]).toBe(0);
});
});
test("should count two items after inserting two documents", async () => {
await t.run(async (ctx) => {
const item1 = await testItem(ctx, { name: "name", value: 1 });
await aggregate.insert(ctx, item1);
await aggregate.insert(
ctx,
await testItem(ctx, { name: "name", value: 2 }),
);
const result = await aggregate.countBatch(ctx, [
{ bounds: { lower: { key: 1, inclusive: true } } },
]);
expect(result.length).toBe(1);
expect(result[0]).toBe(2);
const result2 = await aggregate.countBatch(ctx, [
{
bounds: {
lower: { key: 1, id: item1._id, inclusive: false },
upper: { key: 2, inclusive: true },
},
},
]);
expect(result2.length).toBe(1);
expect(result2[0]).toBe(1);
});
});
});
describe("sumBatch", () => {
let t: ConvexTest;
const { aggregate } = createAggregates();
beforeEach(() => {
t = setupTest();
});
test("should sum zero in empty table", async () => {
await t.run(async (ctx) => {
const result = await aggregate.sumBatch(ctx, [
{ bounds: { lower: { key: 0, inclusive: true } } },
{ bounds: { lower: { key: 0, inclusive: false } } },
]);
expect(result.length).toBe(2);
expect(result[0]).toBe(0);
expect(result[1]).toBe(0);
const result2 = await aggregate.sumBatch(ctx, [{}, {}]);
expect(result2.length).toBe(2);
expect(result2[0]).toBe(0);
expect(result2[1]).toBe(0);
});
});
test("should sum items with different sumValues across multiple ranges", async () => {
await t.run(async (ctx) => {
const item1 = await testItem(ctx, { name: "name", value: 10 });
await aggregate.insert(ctx, item1);
const item2 = await testItem(ctx, { name: "name", value: 20 });
await aggregate.insert(ctx, item2);
const item3 = await testItem(ctx, { name: "name", value: 30 });
await aggregate.insert(ctx, item3);
const result = await aggregate.sumBatch(ctx, [
{ bounds: { lower: { key: 10, inclusive: true } } }, // All items: 10 + 20 + 30 = 60
{ bounds: { lower: { key: 20, inclusive: true } } }, // Items 2,3: 20 + 30 = 50
{
bounds: {
lower: { key: 10, inclusive: true },
upper: { key: 20, inclusive: true },
},
}, // Items 1,2: 10 + 20 = 30
]);
expect(result.length).toBe(3);
expect(result[0]).toBe(60);
expect(result[1]).toBe(50);
expect(result[2]).toBe(30);
});
});
test("should handle exclusive bounds correctly", async () => {
await t.run(async (ctx) => {
const item1 = await testItem(ctx, { name: "name", value: 100 });
await aggregate.insert(ctx, item1);
const item2 = await testItem(ctx, { name: "name", value: 200 });
await aggregate.insert(ctx, item2);
const result = await aggregate.sumBatch(ctx, [
{
bounds: {
lower: { key: 100, id: item1._id, inclusive: false },
upper: { key: 200, inclusive: true },
},
}, // Only item2: 200
]);
expect(result.length).toBe(1);
expect(result[0]).toBe(200);
});
});
});
describe("atBatch", () => {
const { aggregate } = createAggregates();
let t: ConvexTest;
beforeEach(() => {
t = setupTest();
});
test("should find the only item in a single item table", async () => {
await t.run(async (ctx) => {
await aggregate.insert(
ctx,
await testItem(ctx, { name: "name", value: 1 }),
);
const result = await aggregate.atBatch(ctx, [{ offset: 0 }]);
expect(result.length).toBe(1);
expect(result[0].key).toBe(1);
expect(result[0].sumValue).toBe(1);
});
});
});
describe("clearAll", () => {
let t: ConvexTest;
let aggregate: ReturnType<typeof createAggregates>["aggregate"];
beforeEach(() => {
vi.useFakeTimers();
t = setupTest();
({ aggregate } = createAggregates());
});
afterEach(() => {
vi.useRealTimers();
});
const exec = async (_aggregate = aggregate) => {
await t.run(async (ctx) => {
await _aggregate.clearAll(ctx);
});
// Wait for scheduled cleanup functions to complete
// if we dont do this vitest will hang
await t.finishAllScheduledFunctions(vi.runAllTimers);
};
test("should clear all data when called on empty aggregate", async () => {
await exec();
const result = await t.run(async (ctx) => {
return await aggregate.count(ctx);
});
expect(result).toBe(0);
});
test("should clear twice all data when called on empty aggregate", async () => {
await exec();
// This used to error, but now it doesn't
await exec();
const result = await t.run(async (ctx) => {
return await aggregate.count(ctx);
});
expect(result).toBe(0);
});
test("should clear all data after inserting documents", async () => {
// Insert some test documents
await t.run(async (ctx) => {
const id1 = await ctx.db.insert("testItems", {
name: "first",
value: 10,
});
const doc1 = await ctx.db.get(id1);
await aggregate.insert(ctx, doc1!);
const id2 = await ctx.db.insert("testItems", {
name: "second",
value: 20,
});
const doc2 = await ctx.db.get(id2);
await aggregate.insert(ctx, doc2!);
const id3 = await ctx.db.insert("testItems", {
name: "third",
value: 30,
});
const doc3 = await ctx.db.get(id3);
await aggregate.insert(ctx, doc3!);
});
// Verify data exists
const countBefore = await t.run(async (ctx) => {
return await aggregate.count(ctx);
});
expect(countBefore).toBe(3);
// Clear all data
await exec();
// Verify data is cleared
const countAfter = await t.run(async (ctx) => {
return await aggregate.count(ctx);
});
expect(countAfter).toBe(0);
// Verify we can call clearAll again without errors
await exec();
const countAfterSecondClear = await t.run(async (ctx) => {
return await aggregate.count(ctx);
});
expect(countAfterSecondClear).toBe(0);
});
});
});
describe("TableAggregate with namespace", () => {
let t: ConvexTest;
let aggregateWithNamespace: ReturnType<
typeof createAggregates
>["aggregateWithNamespace"];
beforeEach(() => {
t = setupTest();
({ aggregateWithNamespace } = createAggregates());
});
describe("count", () => {
test("should allow count with namespace only (no bounds)", async () => {
// With the updated type system, bounds are now optional even with namespace
const result = await t.run(async (ctx) => {
// This should work - namespace only, no bounds required
return await aggregateWithNamespace.count(ctx, {
namespace: "album1",
});
});
expect(result).toBe(0);
});
test("should allow count with namespace and bounds", async () => {
// You can still provide bounds if you want to
const result = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "album1",
bounds: { lower: { key: 0, inclusive: true } },
});
});
expect(result).toBe(0);
});
test("should demonstrate namespace requirement with actual data", async () => {
// Insert some test photos in different albums
await t.run(async (ctx) => {
const id1 = await ctx.db.insert("photos", {
album: "vacation",
url: "photo1.jpg",
score: 10,
});
const doc1 = await ctx.db.get(id1);
await aggregateWithNamespace.insert(ctx, doc1!);
const id2 = await ctx.db.insert("photos", {
album: "vacation",
url: "photo2.jpg",
score: 20,
});
const doc2 = await ctx.db.get(id2);
await aggregateWithNamespace.insert(ctx, doc2!);
const id3 = await ctx.db.insert("photos", {
album: "family",
url: "photo3.jpg",
score: 30,
});
const doc3 = await ctx.db.get(id3);
await aggregateWithNamespace.insert(ctx, doc3!);
});
// Count photos in "vacation" album - bounds no longer required
const vacationCount = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
});
});
expect(vacationCount).toBe(2);
// Count photos in "family" album
const familyCount = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "family",
});
});
expect(familyCount).toBe(1);
// You can still use bounds if you want to limit the range within a namespace
const vacationCountWithBounds = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
bounds: {},
});
});
expect(vacationCountWithBounds).toBe(2);
});
test("should respect bounds when provided with namespace", async () => {
// Insert photos with explicit scores for predictable bounds testing
await t.run(async (ctx) => {
const doc1 = { album: "vacation", url: "photo1.jpg", score: 10 };
const doc2 = { album: "vacation", url: "photo2.jpg", score: 20 };
const doc3 = { album: "vacation", url: "photo3.jpg", score: 30 };
const id1 = await ctx.db.insert("photos", doc1);
const id2 = await ctx.db.insert("photos", doc2);
const id3 = await ctx.db.insert("photos", doc3);
const insertedDoc1 = await ctx.db.get(id1);
const insertedDoc2 = await ctx.db.get(id2);
const insertedDoc3 = await ctx.db.get(id3);
await aggregateWithNamespace.insert(ctx, insertedDoc1!);
await aggregateWithNamespace.insert(ctx, insertedDoc2!);
await aggregateWithNamespace.insert(ctx, insertedDoc3!);
});
// Count all photos in vacation album (no bounds)
const totalCount = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
});
});
expect(totalCount).toBe(3);
// Count with lower bound - should exclude first photo (score 10)
const countFromSecond = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
bounds: {
lower: { key: 20, inclusive: true },
},
});
});
expect(countFromSecond).toBe(2); // photos with score 20 and 30
// Count with upper bound - should exclude third photo (score 30)
const countUpToSecond = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
bounds: {
upper: { key: 20, inclusive: true },
},
});
});
expect(countUpToSecond).toBe(2); // photos with score 10 and 20
// Count with both bounds - should only include middle photo
const countMiddleOnly = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
bounds: {
lower: { key: 20, inclusive: true },
upper: { key: 20, inclusive: true },
},
});
});
expect(countMiddleOnly).toBe(1); // only photo with score 20
// Test simple lower bound
const countWithLowerBound = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
bounds: {
lower: { key: 25, inclusive: true }, // Should only include photo with score 30
},
});
});
expect(countWithLowerBound).toBe(1); // Only photo with score 30
// Test upper bound
const countWithUpperBound = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
bounds: {
upper: { key: 15, inclusive: true }, // Should only include photo with score 10
},
});
});
expect(countWithUpperBound).toBe(1); // Only photo with score 10
});
test("comprehensive bounds and namespace test", async () => {
// Insert test data across multiple namespaces
await t.run(async (ctx) => {
// Vacation album photos
const vacation1 = { album: "vacation", url: "v1.jpg", score: 5 };
const vacation2 = { album: "vacation", url: "v2.jpg", score: 15 };
const vacation3 = { album: "vacation", url: "v3.jpg", score: 25 };
// Family album photos
const family1 = { album: "family", url: "f1.jpg", score: 10 };
const family2 = { album: "family", url: "f2.jpg", score: 20 };
for (const doc of [vacation1, vacation2, vacation3, family1, family2]) {
const id = await ctx.db.insert("photos", doc);
const insertedDoc = await ctx.db.get(id);
await aggregateWithNamespace.insert(ctx, insertedDoc!);
}
});
// Test: Count all vacation photos (no bounds required!)
const allVacationCount = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
});
});
expect(allVacationCount).toBe(3);
// Test: Count all family photos (no bounds required!)
const allFamilyCount = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "family",
});
});
expect(allFamilyCount).toBe(2);
// Test: Count vacation photos with bounds - only high scores
const highScoreVacationCount = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
bounds: {
lower: { key: 20, inclusive: true }, // score >= 20
},
});
});
expect(highScoreVacationCount).toBe(1); // Only score 25
// Test: Count family photos with bounds - only low scores
const lowScoreFamilyCount = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "family",
bounds: {
upper: { key: 15, inclusive: true }, // score <= 15
},
});
});
expect(lowScoreFamilyCount).toBe(1); // Only score 10
});
test("should isolate bounds within different namespaces", async () => {
await t.run(async (ctx) => {
// Insert photo in vacation album with score 15
const vacationDoc = {
album: "vacation",
url: "vacation1.jpg",
score: 15,
};
const vacationId = await ctx.db.insert("photos", vacationDoc);
const insertedVacationDoc = await ctx.db.get(vacationId);
await aggregateWithNamespace.insert(ctx, insertedVacationDoc!);
// Insert photo in family album with score 25
const familyDoc = { album: "family", url: "family1.jpg", score: 25 };
const familyId = await ctx.db.insert("photos", familyDoc);
const insertedFamilyDoc = await ctx.db.get(familyId);
await aggregateWithNamespace.insert(ctx, insertedFamilyDoc!);
});
// Using bounds that would exclude family photo shouldn't affect family namespace count
const familyCount = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "family",
bounds: {
upper: { key: 20, inclusive: true }, // Family photo has score 25, so this excludes it
},
});
});
expect(familyCount).toBe(0);
// Count family photos without bounds should still work
const familyCountNoBounds = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "family",
});
});
expect(familyCountNoBounds).toBe(1);
// Count vacation photos with bounds that would include family photo score
const vacationCount = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
bounds: {
upper: { key: 30, inclusive: true }, // Would include family score, but family is different namespace
},
});
});
// Should be 1 - only the vacation photo, not affected by family photo
expect(vacationCount).toBe(1);
});
});
describe("inclusive bounds behavior", () => {
let t: ConvexTest;
let aggregate: ReturnType<typeof createAggregates>["aggregate"];
beforeEach(() => {
t = setupTest();
({ aggregate } = createAggregates());
});
test("should respect inclusive vs exclusive lower bounds", async () => {
await t.run(async (ctx) => {
const docs = [
{ name: "item1", value: 10 },
{ name: "item2", value: 20 },
{ name: "item3", value: 30 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregate.insert(ctx, insertedDoc!);
}
});
const countInclusiveLower = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: {
lower: { key: 20, inclusive: true },
},
});
});
expect(countInclusiveLower).toBe(2);
const countExclusiveLower = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: {
lower: { key: 20, inclusive: false },
},
});
});
expect(countExclusiveLower).toBe(1);
});
test("should respect inclusive vs exclusive upper bounds", async () => {
await t.run(async (ctx) => {
const docs = [
{ name: "item1", value: 10 },
{ name: "item2", value: 20 },
{ name: "item3", value: 30 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregate.insert(ctx, insertedDoc!);
}
});
const countInclusiveUpper = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: {
upper: { key: 20, inclusive: true },
},
});
});
expect(countInclusiveUpper).toBe(2);
const countExclusiveUpper = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: {
upper: { key: 20, inclusive: false },
},
});
});
expect(countExclusiveUpper).toBe(1);
});
test("should respect inclusive vs exclusive bounds with both lower and upper", async () => {
await t.run(async (ctx) => {
const docs = [
{ name: "item1", value: 10 },
{ name: "item2", value: 20 },
{ name: "item3", value: 30 },
{ name: "item4", value: 40 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregate.insert(ctx, insertedDoc!);
}
});
const countBothInclusive = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: {
lower: { key: 20, inclusive: true },
upper: { key: 30, inclusive: true },
},
});
});
expect(countBothInclusive).toBe(2);
const countBothExclusive = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: {
lower: { key: 20, inclusive: false },
upper: { key: 30, inclusive: false },
},
});
});
expect(countBothExclusive).toBe(0);
const countMixed1 = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: {
lower: { key: 20, inclusive: true },
upper: { key: 30, inclusive: false },
},
});
});
expect(countMixed1).toBe(1);
const countMixed2 = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: {
lower: { key: 20, inclusive: false },
upper: { key: 30, inclusive: true },
},
});
});
expect(countMixed2).toBe(1);
});
test("should respect inclusive bounds with exact boundary matches", async () => {
await t.run(async (ctx) => {
const docs = [
{ name: "item1", value: 15 },
{ name: "item2", value: 20 },
{ name: "item3", value: 20 },
{ name: "item4", value: 25 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregate.insert(ctx, insertedDoc!);
}
});
const countInclusiveLowerDupe = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: {
lower: { key: 20, inclusive: true },
},
});
});
expect(countInclusiveLowerDupe).toBe(3);
const countExclusiveLowerDupe = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: {
lower: { key: 20, inclusive: false },
},
});
});
expect(countExclusiveLowerDupe).toBe(1);
});
test("should respect inclusive bounds with array keys", async () => {
const aggregateWithArrayKeys = new TableAggregate(components.aggregate, {
sortKey: (doc) => [doc.value, doc.name],
});
await t.run(async (ctx) => {
const docs = [
{ name: "a", value: 10 },
{ name: "b", value: 20 },
{ name: "c", value: 20 },
{ name: "d", value: 30 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregateWithArrayKeys.insert(ctx, insertedDoc!);
}
});
const countInclusiveArrayLower = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: {
lower: { key: [20, "b"], inclusive: true },
},
});
});
expect(countInclusiveArrayLower).toBe(3);
const countExclusiveArrayLower = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: {
lower: { key: [20, "b"], inclusive: false },
},
});
});
expect(countExclusiveArrayLower).toBe(2);
const countInclusiveArrayUpper = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: {
upper: { key: [20, "c"], inclusive: true },
},
});
});
expect(countInclusiveArrayUpper).toBe(3);
const countExclusiveArrayUpper = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: {
upper: { key: [20, "c"], inclusive: false },
},
});
});
expect(countExclusiveArrayUpper).toBe(2);
});
});
});
describe("Bounds with eq", () => {
let t: ConvexTest;
let aggregate: ReturnType<typeof createAggregates>["aggregate"];
beforeEach(() => {
t = setupTest();
({ aggregate } = createAggregates());
});
test("should count items with eq bound on non-array key", async () => {
await t.run(async (ctx) => {
const docs = [
{ name: "item1", value: 10 },
{ name: "item2", value: 20 },
{ name: "item3", value: 20 },
{ name: "item4", value: 30 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregate.insert(ctx, insertedDoc!);
}
});
// Test eq bound - should only count items with exact key value
const countEq20 = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: { eq: 20 },
});
});
expect(countEq20).toBe(2); // Two items with value 20
const countEq10 = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: { eq: 10 },
});
});
expect(countEq10).toBe(1); // One item with value 10
const countEq30 = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: { eq: 30 },
});
});
expect(countEq30).toBe(1); // One item with value 30
const countEq40 = await t.run(async (ctx) => {
return await aggregate.count(ctx, {
bounds: { eq: 40 },
});
});
expect(countEq40).toBe(0); // No items with value 40
});
test("should sum items with eq bound on non-array key", async () => {
await t.run(async (ctx) => {
const docs = [
{ name: "item1", value: 15 },
{ name: "item2", value: 15 },
{ name: "item3", value: 25 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregate.insert(ctx, insertedDoc!);
}
});
const sumEq15 = await t.run(async (ctx) => {
return await aggregate.sum(ctx, {
bounds: { eq: 15 },
});
});
expect(sumEq15).toBe(30); // 15 + 15 = 30
const sumEq25 = await t.run(async (ctx) => {
return await aggregate.sum(ctx, {
bounds: { eq: 25 },
});
});
expect(sumEq25).toBe(25);
const sumEq100 = await t.run(async (ctx) => {
return await aggregate.sum(ctx, {
bounds: { eq: 100 },
});
});
expect(sumEq100).toBe(0); // No items with value 100
});
test("should work with eq bound on array keys", async () => {
const aggregateWithArrayKeys = new TableAggregate<{
Key: [number, string];
DataModel: DataModel;
TableName: "testItems";
}>(components.aggregate, {
sortKey: (doc) => [doc.value, doc.name],
sumValue: (doc) => doc.value,
});
await t.run(async (ctx) => {
const docs = [
{ name: "a", value: 10 },
{ name: "b", value: 20 },
{ name: "c", value: 20 },
{ name: "d", value: 30 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregateWithArrayKeys.insert(ctx, insertedDoc!);
}
});
// Test eq bound with exact array match
const countEqArray = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: { eq: [20, "b"] },
});
});
expect(countEqArray).toBe(1); // Only one item with exact key [20, "b"]
const countEqArray2 = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: { eq: [20, "c"] },
});
});
expect(countEqArray2).toBe(1); // Only one item with exact key [20, "c"]
const countEqArrayNonExistent = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: { eq: [20, "z"] },
});
});
expect(countEqArrayNonExistent).toBe(0); // No items with key [20, "z"]
});
test("should work with eq bound and namespace", async () => {
const { aggregateWithNamespace } = createAggregates();
await t.run(async (ctx) => {
const docs = [
{ album: "vacation", url: "photo1.jpg", score: 10 },
{ album: "vacation", url: "photo2.jpg", score: 20 },
{ album: "vacation", url: "photo3.jpg", score: 20 },
{ album: "family", url: "photo4.jpg", score: 20 },
];
for (const doc of docs) {
const id = await ctx.db.insert("photos", doc);
const insertedDoc = await ctx.db.get(id);
await aggregateWithNamespace.insert(ctx, insertedDoc!);
}
});
// Test eq bound within vacation namespace
const vacationCountEq20 = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
bounds: { eq: 20 },
});
});
expect(vacationCountEq20).toBe(2); // Two vacation photos with score 20
// Test eq bound within family namespace
const familyCountEq20 = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "family",
bounds: { eq: 20 },
});
});
expect(familyCountEq20).toBe(1); // One family photo with score 20
// Test eq bound with non-existent value in namespace
const vacationCountEq100 = await t.run(async (ctx) => {
return await aggregateWithNamespace.count(ctx, {
namespace: "vacation",
bounds: { eq: 100 },
});
});
expect(vacationCountEq100).toBe(0);
});
test("should use eq bounds in batch operations", async () => {
await t.run(async (ctx) => {
const docs = [
{ name: "item1", value: 10 },
{ name: "item2", value: 20 },
{ name: "item3", value: 20 },
{ name: "item4", value: 30 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregate.insert(ctx, insertedDoc!);
}
});
// Test countBatch with multiple eq bounds
const counts = await t.run(async (ctx) => {
return await aggregate.countBatch(ctx, [
{ bounds: { eq: 10 } },
{ bounds: { eq: 20 } },
{ bounds: { eq: 30 } },
{ bounds: { eq: 40 } },
]);
});
expect(counts).toEqual([1, 2, 1, 0]);
// Test sumBatch with eq bounds
const sums = await t.run(async (ctx) => {
return await aggregate.sumBatch(ctx, [
{ bounds: { eq: 10 } },
{ bounds: { eq: 20 } },
{ bounds: { eq: 30 } },
]);
});
expect(sums).toEqual([10, 40, 30]);
});
});
describe("Bounds with prefix on array keys", () => {
let t: ConvexTest;
beforeEach(() => {
t = setupTest();
});
test("should still work with prefix bounds on array keys", async () => {
const aggregateWithArrayKeys = new TableAggregate<{
Key: [number, string];
DataModel: DataModel;
TableName: "testItems";
}>(components.aggregate, {
sortKey: (doc) => [doc.value, doc.name],
sumValue: (doc) => doc.value,
});
await t.run(async (ctx) => {
const docs = [
{ name: "a", value: 10 },
{ name: "b", value: 10 },
{ name: "c", value: 20 },
{ name: "d", value: 20 },
{ name: "e", value: 30 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregateWithArrayKeys.insert(ctx, insertedDoc!);
}
});
// Test prefix bound - should match all items with value 10
const countPrefix10 = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: { prefix: [10] },
});
});
expect(countPrefix10).toBe(2); // Two items with first element 10
const countPrefix20 = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: { prefix: [20] },
});
});
expect(countPrefix20).toBe(2); // Two items with first element 20
// Test empty prefix - should match all items
const countPrefixEmpty = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: { prefix: [] },
});
});
expect(countPrefixEmpty).toBe(5); // All items
// Test full prefix - should match exact item
const countPrefixFull = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.count(ctx, {
bounds: { prefix: [10, "a"] },
});
});
expect(countPrefixFull).toBe(1); // Only [10, "a"]
});
test("should sum with prefix bounds on array keys", async () => {
const aggregateWithArrayKeys = new TableAggregate<{
Key: [number, string];
DataModel: DataModel;
TableName: "testItems";
}>(components.aggregate, {
sortKey: (doc) => [doc.value, doc.name],
sumValue: (doc) => doc.value,
});
await t.run(async (ctx) => {
const docs = [
{ name: "a", value: 10 },
{ name: "b", value: 10 },
{ name: "c", value: 20 },
];
for (const doc of docs) {
const id = await ctx.db.insert("testItems", doc);
const insertedDoc = await ctx.db.get(id);
await aggregateWithArrayKeys.insert(ctx, insertedDoc!);
}
});
const sumPrefix10 = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.sum(ctx, {
bounds: { prefix: [10] },
});
});
expect(sumPrefix10).toBe(20); // 10 + 10 = 20
const sumPrefix20 = await t.run(async (ctx) => {
return await aggregateWithArrayKeys.sum(ctx, {
bounds: { prefix: [20] },
});
});
expect(sumPrefix20).toBe(20);
});
});