Skip to main content

Query Optimization

This guide covers efficient data retrieval patterns in BunSane. Proper query design is critical for application performance.

Query Basics

import { Query } from "bunsane/query";

// Basic query - find all entities with a component
const users = await new Query()
.with(UserTag)
.exec();

Query Building Patterns

Pattern 1: Multiple Component Requirements

Specify all required components to narrow results.

// Find entities that have ALL specified components
const verifiedUsers = await new Query()
.with(UserTag)
.with(EmailComponent)
.with(VerifiedTag)
.exec();

Pattern 2: Filtering by Field Values

Use Query.filters() and Query.filter() for field-level conditions.

const users = await new Query()
.with(
EmailComponent,
Query.filters(
Query.filter("value", Query.filterOp.EQ, "john@example.com")
)
)
.exec();

Pattern 3: Multiple Filters on Same Component

const products = await new Query()
.with(
ProductInventoryComponent,
Query.filters(
Query.filter("quantity", Query.filterOp.GT, 0),
Query.filter("sku", Query.filterOp.LIKE, "PROD-%")
)
)
.exec();

Pattern 4: Exclusion Queries

Use .without() to exclude entities with certain components.

// Active users (not deleted)
const activeUsers = await new Query()
.with(UserTag)
.without(SoftDeletedTag)
.exec();

Filter Operators Reference

OperatorDescriptionExample
EQEqualsQuery.filter("status", Query.filterOp.EQ, "active")
NEQNot equalsQuery.filter("status", Query.filterOp.NEQ, "deleted")
GTGreater thanQuery.filter("price", Query.filterOp.GT, 100)
GTEGreater than or equalQuery.filter("quantity", Query.filterOp.GTE, 1)
LTLess thanQuery.filter("age", Query.filterOp.LT, 18)
LTELess than or equalQuery.filter("price", Query.filterOp.LTE, 50)
LIKEPattern matchingQuery.filter("name", Query.filterOp.LIKE, "John%")
INValue in arrayQuery.filter("status", Query.filterOp.IN, ["active", "pending"])
NOT_INValue not in arrayQuery.filter("status", Query.filterOp.NOT_IN, ["deleted"])

Pagination and Sorting

Basic Pagination

const pageSize = 20;
const pageNumber = 1;

const users = await new Query()
.with(UserTag)
.with(ProfileComponent)
.sortBy(ProfileComponent, "createdAt", "DESC")
.take(pageSize)
.offset(pageNumber * pageSize)
.exec();

Counting Results

// Get total count for pagination UI
const totalCount = await new Query()
.with(UserTag)
.count();

// Calculate total pages
const totalPages = Math.ceil(totalCount / pageSize);

Optimization Strategies

Strategy 1: Index Frequently Filtered Fields

Ensure fields used in filters have { indexed: true }.

@Component
export class EmailComponent extends BaseComponent {
@CompData({ indexed: true }) // Index for EQ lookups
value: string = "";

@CompData() // No index needed - rarely filtered
verified: boolean = false;
}

Strategy 2: Use Tags Instead of Boolean Filters

Tags are more efficient than filtering by boolean fields.

// SLOWER - Boolean filter
const verifiedUsers = await new Query()
.with(UserTag)
.with(
EmailComponent,
Query.filters(
Query.filter("verified", Query.filterOp.EQ, true)
)
)
.exec();

// FASTER - Tag-based query
const verifiedUsers = await new Query()
.with(UserTag)
.with(EmailVerifiedTag) // Tag added when verified
.exec();

Strategy 3: Limit Result Sets

Always use .take() for potentially large result sets.

// BAD - Could return thousands of rows
const allOrders = await new Query()
.with(OrderTag)
.exec();

// GOOD - Paginated
const orders = await new Query()
.with(OrderTag)
.with(OrderInfoComponent)
.sortBy(OrderInfoComponent, "createdAt", "DESC")
.take(100)
.exec();

Strategy 4: Use Count Before Large Operations

Check count before executing potentially expensive operations.

const pendingCount = await new Query()
.with(OrderTag)
.with(
OrderStatusComponent,
Query.filters(Query.filter("value", Query.filterOp.EQ, "pending"))
)
.count();

if (pendingCount > 1000) {
// Process in batches
await processInBatches();
} else {
// Process all at once
const orders = await query.exec();
}

Strategy 5: Narrow Queries with Multiple Components

Adding more .with() clauses narrows results more efficiently than adding filters.

// Less efficient - Filter after broad query
const adminUsers = await new Query()
.with(UserTag)
.with(
RoleComponent,
Query.filters(Query.filter("value", Query.filterOp.EQ, "admin"))
)
.exec();

// More efficient - Use tag for role
const adminUsers = await new Query()
.with(UserTag)
.with(AdminTag) // Entities are indexed by component presence
.exec();

Common Query Patterns

Find by Unique Field

async function findUserByEmail(email: string): Promise<Entity | null> {
const results = await new Query()
.with(UserTag)
.with(
EmailComponent,
Query.filters(Query.filter("value", Query.filterOp.EQ, email))
)
.take(1)
.exec();

return results[0] || null;
}

Find by ID with Validation

async function findUserById(id: string): Promise<Entity> {
const user = await Entity.FindById(id);
if (!user) {
throw new GraphQLError("User not found", {
extensions: { code: "NOT_FOUND" }
});
}

// Optionally verify it's actually a user
const hasUserTag = await user.get(UserTag);
if (!hasUserTag) {
throw new GraphQLError("Entity is not a user", {
extensions: { code: "INVALID_TYPE" }
});
}

return user;
}

Paginated List Query

interface PaginationArgs {
page?: number;
pageSize?: number;
sortOrder?: "ASC" | "DESC";
}

async function listUsers(args: PaginationArgs) {
const page = args.page || 0;
const pageSize = Math.min(args.pageSize || 20, 100); // Max 100
const sortOrder = args.sortOrder || "DESC";

const [items, totalCount] = await Promise.all([
new Query()
.with(UserTag)
.with(ProfileComponent)
.sortBy(ProfileComponent, "createdAt", sortOrder)
.take(pageSize)
.offset(page * pageSize)
.exec(),
new Query()
.with(UserTag)
.count()
]);

return {
items,
totalCount,
page,
pageSize,
totalPages: Math.ceil(totalCount / pageSize),
};
}

Search with Multiple Criteria

interface UserSearchCriteria {
email?: string;
name?: string;
verified?: boolean;
}

async function searchUsers(criteria: UserSearchCriteria) {
let query = new Query().with(UserTag);

if (criteria.email) {
query = query.with(
EmailComponent,
Query.filters(
Query.filter("value", Query.filterOp.LIKE, `%${criteria.email}%`)
)
);
}

if (criteria.name) {
query = query.with(
NameComponent,
Query.filters(
Query.filter("value", Query.filterOp.LIKE, `%${criteria.name}%`)
)
);
}

if (criteria.verified === true) {
query = query.with(EmailVerifiedTag);
} else if (criteria.verified === false) {
query = query.without(EmailVerifiedTag);
}

return await query.take(100).exec();
}

Transactions in Queries

When reading entities that will be modified, use transactions.

import db from "bunsane/database";

const result = await db.transaction(async (trx) => {
// Read within transaction
const user = await Entity.FindById(userId, trx);
if (!user) throw new Error("User not found");

const balance = await user.get(BalanceComponent, { trx });

// Modify
await user.set(BalanceComponent, {
amount: balance.amount - 100
}, { trx });

await user.save(trx);

return user;
});

Performance Monitoring Tips

  1. Log slow queries: Add timing to track query performance
  2. Use EXPLAIN: For complex queries, analyze with PostgreSQL's EXPLAIN
  3. Monitor result counts: Track how many entities queries typically return
  4. Index appropriately: Add indexes based on actual query patterns
// Example timing wrapper
async function timedQuery<T>(name: string, queryFn: () => Promise<T>): Promise<T> {
const start = performance.now();
const result = await queryFn();
const duration = performance.now() - start;

if (duration > 100) {
console.warn(`Slow query "${name}": ${duration.toFixed(2)}ms`);
}

return result;
}

// Usage
const users = await timedQuery("listActiveUsers", () =>
new Query()
.with(UserTag)
.without(SoftDeletedTag)
.take(100)
.exec()
);

Advanced Query Methods

The Query class includes additional methods for specialized use cases:

Aggregate Functions

// Sum a numeric field
const totalRevenue = await new Query()
.with(OrderTag)
.with(OrderAmountComponent)
.sum(OrderAmountComponent, "amount");

// Average a numeric field
const avgOrderValue = await new Query()
.with(OrderTag)
.with(OrderAmountComponent)
.average(OrderAmountComponent, "amount");

Cursor-Based Pagination

More efficient than offset-based pagination for large datasets:

// First page
const firstPage = await new Query()
.with(UserTag)
.with(ProfileComponent)
.sortBy(ProfileComponent, "createdAt", "DESC")
.take(20)
.exec();

// Next page using cursor (last entity's ID)
const lastId = firstPage[firstPage.length - 1]?.id;
const nextPage = await new Query()
.with(UserTag)
.with(ProfileComponent)
.sortBy(ProfileComponent, "createdAt", "DESC")
.cursor(lastId, "after")
.take(20)
.exec();

Eager Loading (Prevent N+1 Queries)

// Preload components to avoid separate queries per entity
const users = await new Query()
.with(UserTag)
.eagerLoadComponents([ProfileComponent, EmailComponent])
.take(100)
.exec();

// Components are now cached on each entity
for (const user of users) {
const profile = await user.get(ProfileComponent); // No additional query
}

Estimated Count (Fast Approximate)

For very large tables where exact count is slow:

// Uses PostgreSQL table statistics - much faster than count()
const approxCount = await new Query()
.with(UserTag)
.estimatedCount(UserTag);

Query Debugging

// Enable debug logging for a query
const users = await new Query()
.with(UserTag)
.debugMode(true)
.exec();

// Get PostgreSQL EXPLAIN ANALYZE output
const plan = await new Query()
.with(UserTag)
.with(ProfileComponent)
.explainAnalyze();
console.log(plan);

Summary

DoDon't
Use .take() for paginationReturn unlimited results
Index fields used in filtersIndex every field
Use tags for boolean statesFilter by boolean fields
Add multiple .with() clausesUse complex filter logic
Use transactions for read-modify-writeRead and write separately
Check .count() before large opsProcess unknown-size results
Use cursor() for large datasetsUse large offset() values
Use eagerLoadComponents() for batch readsQuery components in loops