Queries

Use the chainable query builder for readable, type‑safe AQL generation. arango-typed provides a fluent, Mongoose-like query API that compiles to efficient AQL queries.

What are Queries?

Queries in arango-typed allow you to find, filter, sort, and retrieve documents from your collections using a chainable, type-safe API. Instead of writing raw AQL, you use a familiar query builder that automatically generates optimized AQL queries.

Queries help you:

  • Type Safety: TypeScript support for query results
  • Readability: Chainable API is easier to read than raw AQL
  • Performance: Automatic query caching and optimization
  • Flexibility: Support for complex filters, sorting, pagination
  • Multi-tenancy: Automatic tenant filtering when enabled

Basic Query Methods

Find - Get Multiple Documents

Use find() to query multiple documents:

import { model, Schema } from 'arango-typed';

const UserSchema = new Schema({
  name: String,
  email: String,
  age: Number,
  active: Boolean
});

const User = model('users', UserSchema);

// Find all active users
const activeUsers = await User.find({ active: true }).all();

// Find users by age
const adults = await User.find({ age: { $gte: 18 } }).all();

// Find users with multiple conditions
const users = await User.find({
  active: true,
  age: { $gte: 18, $lte: 65 }
}).all();

FindOne - Get Single Document

Use findOne() to get a single document:

// Find one user by email
const user = await User.findOne({ email: 'john@example.com' });

if (user) {
  console.log(user.name);
} else {
  console.log('User not found');
}

// Find one with conditions
const activeUser = await User.findOne({
  email: 'john@example.com',
  active: true
});

FindById - Get Document by ID

Use findById() to get a document by its ID:

// Find by ID
const user = await User.findById('users/123');

if (user) {
  console.log(user.name);
} else {
  console.log('User not found');
}

// IDs can be in different formats
const user1 = await User.findById('users/123');        // Full ID
const user2 = await User.findById('123');              // Key only (if collection name is known)

Query Operators

arango-typed supports various query operators for flexible filtering:

Comparison Operators

// Greater than or equal
const adults = await User.find({ age: { $gte: 18 } }).all();

// Less than or equal
const seniors = await User.find({ age: { $lte: 65 } }).all();

// Greater than
const youngAdults = await User.find({ age: { $gt: 18 } }).all();

// Less than
const minors = await User.find({ age: { $lt: 18 } }).all();

// Not equal
const inactiveUsers = await User.find({ active: { $ne: true } }).all();

// Equal (explicit)
const activeUsers = await User.find({ active: { $eq: true } }).all();
// Or simply:
const activeUsers2 = await User.find({ active: true }).all();

Array Operators

// In array
const users = await User.find({ 
  role: { $in: ['admin', 'editor', 'moderator'] }
}).all();

// Not in array
const regularUsers = await User.find({ 
  role: { $nin: ['admin', 'editor'] }
}).all();

// Array size
const usersWithManyPosts = await User.find({ 
  posts: { $size: { $gte: 10 } }
}).all();

// All elements match
const usersWithAllTags = await User.find({ 
  tags: { $all: ['javascript', 'typescript'] }
}).all();

String Operators

// Regex pattern matching
const johns = await User.find({ 
  name: { $regex: /^John/i }
}).all();

// Or with string pattern
const johns2 = await User.find({ 
  name: { $regex: '^John', $options: 'i' }
}).all();

// Text search (requires fulltext index)
const results = await User.find({ 
  bio: { $text: 'developer' }
}).all();

Partial Text Search (Automatic)

arango-typed automatically performs case-insensitive partial text search for fields ending with Contains. This is a simple and intuitive way to search for partial matches without using regex operators.

How it works: When you use a field name ending with Contains (case-insensitive), the library automatically:

  • Extracts the actual field name by removing the Contains suffix
  • Performs a case-insensitive LIKE search with the value
  • Matches any document where the field contains the search term
// Automatic partial text search
// Searches for documents where 'name' field contains 'john' (case-insensitive)
const users = await User.find({ 
  nameContains: 'john'
}).all();
// Generates: LOWER(doc.name) LIKE '%john%'

// Multiple partial searches
const results = await User.find({
  nameContains: 'john',
  codeContains: 'ABC'
}).all();
// Searches for name containing 'john' AND code containing 'ABC'

// Works with nested fields
const users = await User.find({
  'user.emailContains': 'gmail'
}).all();
// Searches for user.email containing 'gmail'

// Combine with other operators
const filtered = await User.find({
  nameContains: 'john',
  age: { $gte: 18 },
  active: true
}).all();

Examples:

  • nameContains: 'john' → searches name field for 'john'
  • codeContains: 'ABC' → searches code field for 'ABC'
  • emailContains: 'gmail' → searches email field for 'gmail'
  • user.emailContains: 'example' → searches nested user.email field

Note: This feature only works with string values. Non-string values or operator objects will use normal exact matching.

Existence Operators

// Field exists
const usersWithEmail = await User.find({ 
  email: { $exists: true }
}).all();

// Field does not exist
const usersWithoutEmail = await User.find({ 
  email: { $exists: false }
}).all();

Logical Operators

// AND (default - multiple conditions)
const users = await User.find({
  active: true,
  age: { $gte: 18 }
}).all();

// OR
const users = await User.find({
  $or: [
    { role: 'admin' },
    { role: 'editor' }
  ]
}).all();

// AND (explicit)
const users = await User.find({
  $and: [
    { active: true },
    { age: { $gte: 18 } }
  ]
}).all();

// NOT
const users = await User.find({
  role: { $not: { $eq: 'admin' } }
}).all();

// NOR (neither condition)
const users = await User.find({
  $nor: [
    { role: 'admin' },
    { role: 'editor' }
  ]
}).all();

Date Operators

// Date comparisons
const recentUsers = await User.find({
  createdAt: { $gte: new Date('2024-01-01') }
}).all();

const oldUsers = await User.find({
  createdAt: { $lt: new Date('2020-01-01') }
}).all();

// Date range
const usersInRange = await User.find({
  createdAt: {
    $gte: new Date('2024-01-01'),
    $lte: new Date('2024-12-31')
  }
}).all();

Chaining Query Methods

Queries are chainable, allowing you to build complex queries step by step:

Where - Filter Conditions

// Chain where conditions
const users = await User.find({ active: true })
  .where({ age: { $gte: 18 } })
  .where({ email: { $exists: true } })
  .all();

// Or combine in single find
const users = await User.find({
  active: true,
  age: { $gte: 18 },
  email: { $exists: true }
}).all();

Select - Projection (Choose Fields)

Select only the fields you need to reduce data transfer:

// Select specific fields
const users = await User.find({ active: true })
  .select(['name', 'email'])
  .all();

// Returns only name and email fields
// Result: [{ name: 'John', email: 'john@example.com' }, ...]

// Select nested fields
const users = await User.find({})
  .select(['name', 'profile.bio', 'profile.avatar'])
  .all();

Sort - Order Results

Sort results by one or more fields:

// Single field sort (descending)
const users = await User.find({})
  .sort({ createdAt: -1 })  // -1 = descending, 1 = ascending
  .all();

// Single field sort (ascending)
const users = await User.find({})
  .sort({ name: 1 })
  .all();

// Multiple field sort
const users = await User.find({})
  .sort({ active: -1, createdAt: -1 })
  .all();
// Sort by active first (descending), then by createdAt (descending)

// Array format for sort
const users = await User.find({})
  .sort([
    { field: 'active', direction: -1 },
    { field: 'createdAt', direction: -1 }
  ])
  .all();

Limit - Limit Results

// Limit to 10 results
const users = await User.find({})
  .limit(10)
  .all();

// Always use limit for large collections
const topUsers = await User.find({ active: true })
  .sort({ score: -1 })
  .limit(100)
  .all();

Skip - Skip Results

// Skip first 10 results
const users = await User.find({})
  .skip(10)
  .limit(10)
  .all();

Combining Methods

// Chain multiple methods
const users = await User.find({ active: true })
  .select(['name', 'email', 'age'])
  .sort({ createdAt: -1 })
  .skip(20)
  .limit(10)
  .all();

// This query:
// 1. Finds active users
// 2. Selects only name, email, and age
// 3. Sorts by createdAt (newest first)
// 4. Skips first 20 results
// 5. Limits to 10 results

Pagination

Use skip() and limit() for pagination:

Basic Pagination

// Pagination helper function
function paginate(page: number, pageSize: number) {
  const skip = (page - 1) * pageSize;
  return { skip, limit: pageSize };
}

// Get page 1 (first 20 results)
const page1 = await User.find({})
  .skip(0)
  .limit(20)
  .all();

// Get page 2 (results 21-40)
const page2 = await User.find({})
  .skip(20)
  .limit(20)
  .all();

// Get page 3 (results 41-60)
const page3 = await User.find({})
  .skip(40)
  .limit(20)
  .all();

Complete Pagination Example

async function getUsersPage(page: number, pageSize: number = 20) {
  const skip = (page - 1) * pageSize;
  
  const [users, total] = await Promise.all([
    User.find({ active: true })
      .sort({ createdAt: -1 })
      .skip(skip)
      .limit(pageSize)
      .all(),
    User.find({ active: true }).count()
  ]);

  return {
    users,
    pagination: {
      page,
      pageSize,
      total,
      totalPages: Math.ceil(total / pageSize),
      hasNext: page * pageSize < total,
      hasPrev: page > 1
    }
  };
}

// Usage
const result = await getUsersPage(1, 20);
console.log(`Page ${result.pagination.page} of ${result.pagination.totalPages}`);
console.log(`Showing ${result.users.length} of ${result.pagination.total} users`);

Nested Field Queries

Query nested fields using dot notation:

const UserSchema = new Schema({
  name: String,
  profile: {
    bio: String,
    avatar: String,
    settings: {
      theme: String,
      notifications: Boolean
    }
  }
});

const User = model('users', UserSchema);

// Query nested fields
const users = await User.find({
  'profile.bio': { $exists: true },
  'profile.settings.theme': 'dark'
}).all();

// Update nested fields
await User.updateOne(
  { _id: 'users/123' },
  { 'profile.settings.theme': 'light' }
);

Complex Queries

Multiple Conditions

// Complex query with multiple conditions
const users = await User.find({
  active: true,
  age: { $gte: 18, $lte: 65 },
  email: { $exists: true, $regex: /@example\.com$/ },
  role: { $in: ['user', 'premium'] },
  createdAt: { $gte: new Date('2024-01-01') }
})
  .sort({ score: -1, createdAt: -1 })
  .limit(50)
  .all();

OR Conditions

// Users who are either admins or have high score
const users = await User.find({
  $or: [
    { role: 'admin' },
    { score: { $gte: 1000 } }
  ]
}).all();

// Multiple OR conditions
const users = await User.find({
  $or: [
    { role: 'admin' },
    { role: 'editor' },
    { verified: true, score: { $gte: 500 } }
  ]
}).all();

Combining AND and OR

// Complex query with AND and OR
const users = await User.find({
  active: true,  // AND condition
  $or: [
    { role: 'admin' },
    { role: 'editor' },
    { verified: true, score: { $gte: 1000 } }  // Nested AND
  ],
  createdAt: { $gte: new Date('2024-01-01') }  // AND condition
}).all();

Query Results

All Results

// Get all results as array
const users = await User.find({ active: true }).all();
// Returns: Document[]

// Always use with limit for large collections
const users = await User.find({ active: true })
  .limit(100)
  .all();

First Result

// Get first result
const user = await User.find({ active: true })
  .sort({ createdAt: -1 })
  .first();
// Returns: Document | null

// Equivalent to findOne but with chaining
const user = await User.find({ active: true })
  .limit(1)
  .first();

Count Results

// Count matching documents
const count = await User.find({ active: true }).count();
console.log(`Found ${count} active users`);

// Count with conditions
const adultCount = await User.find({ age: { $gte: 18 } }).count();

Lean Queries

Lean queries return plain JavaScript objects instead of Document instances, providing better performance and lower memory usage.

When to Use Lean Queries

  • Read-only operations
  • When you don't need Document methods (save, remove, etc.)
  • High-frequency queries
  • Large result sets
  • API responses

Using Lean Queries

// Regular query (returns Document instances)
const users = await User.find({ active: true }).all();
users[0].save();  // Document methods available

// Lean query (returns plain objects)
const users = await User.findLean({ active: true }).all();
// users[0].save();  // Error: plain object, no Document methods

// Lean queries support all chaining methods
const users = await User.findLean({ active: true })
  .select(['name', 'email'])
  .sort({ createdAt: -1 })
  .limit(100)
  .all();

// Performance comparison
// Regular query: ~100ms, ~10MB memory
// Lean query: ~85ms, ~6MB memory (15-25% faster, 30-40% less memory)

Query Caching

arango-typed automatically caches compiled queries for better performance:

How It Works

// First call - compiles and caches query structure
await User.find({ name: 'John' }).all();

// Subsequent calls - uses cached query (faster!)
await User.find({ name: 'Jane' }).all();  // Same structure, different value
await User.find({ name: 'Bob' }).all();    // Same structure, different value

// Cache is based on query structure, not values
// Same cache key: same collection + same query structure

Cache Benefits

  • ⚡ 20-30% faster for repeated queries
  • 💾 Reduced CPU usage (no recompilation)
  • 🚀 Significant speedup for high-frequency queries

Raw AQL Queries

For complex queries that can't be expressed with the query builder, you can use raw AQL:

Execute Raw AQL

// Execute raw AQL query
const results = await User.find({}).execute(`
  FOR user IN users
  FILTER user.active == true
  SORT user.createdAt DESC
  LIMIT 10
  RETURN user
`);

// With bind variables
const results = await User.find({}).execute(`
  FOR user IN users
  FILTER user.email == @email
  RETURN user
`, { email: 'john@example.com' });

Using getDatabase() for Raw Queries

import { getDatabase } from 'arango-typed';

const db = getDatabase();

// Execute raw AQL
const cursor = await db.query(`
  FOR user IN users
  FILTER user.active == true
  FOR post IN posts
  FILTER post.userId == user._id
  COLLECT userId = user._id WITH COUNT INTO postCount
  RETURN { userId, postCount }
`);

const results = await cursor.all();

Subqueries

Use subqueries for complex conditions:

EXISTS Subquery

import { Subquery } from 'arango-typed';

// Find users who have posts
const usersWithPosts = await User.find({}).execute(`
  FOR user IN users
  FILTER ${Subquery.exists({
    collection: 'posts',
    where: { userId: 'user._id' }
  })}
  RETURN user
`);

IN Subquery

// Find users whose role is in a subquery result
const users = await User.find({}).execute(`
  FOR user IN users
  FILTER user.role IN ${Subquery.create({
    collection: 'roles',
    where: { active: true },
    select: ['name']
  })}
  RETURN user
`);

Join Queries

Join multiple collections in a single query:

Left Join

import { JoinQuery } from 'arango-typed';

const db = getDatabase();

// Join users with profiles
const joinQuery = new JoinQuery(db, 'users', {
  joins: [
    {
      collection: 'profiles',
      options: {
        type: 'left',
        on: { localField: '_id', foreignField: 'userId' },
        as: 'profile'
      }
    }
  ],
  select: ['name', 'email', 'profile.bio', 'profile.avatar']
});

const results = await joinQuery.execute();

Inner Join

// Inner join - only users with profiles
const joinQuery = new JoinQuery(db, 'users', {
  joins: [
    {
      collection: 'profiles',
      options: {
        type: 'inner',
        on: { localField: '_id', foreignField: 'userId' },
        as: 'profile'
      }
    }
  ]
});

const results = await joinQuery.execute();

Query with Multi-Tenancy

When multi-tenancy is enabled, queries automatically filter by tenant:

// Model with multi-tenancy enabled
const User = model('users', UserSchema, { tenantEnabled: true });

// This query automatically filters by current tenant
const users = await User.find({ active: true }).all();
// Only returns users for the current tenant

// Tenant filtering is automatic - no need to add tenantId manually

Error Handling

Always handle errors when executing queries:

try {
  const users = await User.find({ active: true }).all();
  console.log(`Found ${users.length} users`);
} catch (error) {
  console.error('Query failed:', error);
  // Handle error appropriately
}

// Or with async/await
try {
  const user = await User.findOne({ email: 'john@example.com' });
  if (!user) {
    console.log('User not found');
  }
} catch (error) {
  console.error('Query error:', error);
}

Query Best Practices

1. Always Use Indexes

// Create indexes on frequently queried fields
UserSchema.index('email');
UserSchema.index('active');
UserSchema.index(['tenantId', 'email']);  // Compound index

// Queries will use indexes automatically
const users = await User.find({ email: 'user@example.com' }).all();

2. Use Projections

// ✅ Good: Select only needed fields
const users = await User.find({})
  .select(['name', 'email'])
  .all();

// ❌ Bad: Fetch all fields
const users = await User.find({}).all();

3. Always Limit Results

// ✅ Good: Always use limit
const users = await User.find({})
  .limit(100)
  .all();

// ❌ Bad: No limit (could fetch millions!)
const users = await User.find({}).all();

4. Use Lean Queries for Read-Only

// ✅ Good: Use lean for read-only
const users = await User.findLean({ active: true })
  .select(['name', 'email'])
  .limit(100)
  .all();

// ❌ Bad: Use Document instances when not needed
const users = await User.find({ active: true }).all();

5. Filter Early

// ✅ Good: Filter before other operations
const users = await User.find({ active: true })
  .sort({ createdAt: -1 })
  .limit(10)
  .all();

// ❌ Bad: Fetch all, then filter
const allUsers = await User.find({}).all();
const activeUsers = allUsers.filter(u => u.active);

6. Use Appropriate Operators

// ✅ Good: Use $in for multiple values
const users = await User.find({
  role: { $in: ['admin', 'editor'] }
}).all();

// ❌ Bad: Multiple OR conditions
const users = await User.find({
  $or: [
    { role: 'admin' },
    { role: 'editor' }
  ]
}).all();

Common Query Patterns

Pattern 1: Search with Filters

async function searchUsers(filters: {
  query?: string;
  role?: string;
  active?: boolean;
  minAge?: number;
  maxAge?: number;
}) {
  const conditions: any = {};

  if (filters.query) {
    conditions.name = { $regex: filters.query, $options: 'i' };
  }

  if (filters.role) {
    conditions.role = filters.role;
  }

  if (filters.active !== undefined) {
    conditions.active = filters.active;
  }

  if (filters.minAge || filters.maxAge) {
    conditions.age = {};
    if (filters.minAge) conditions.age.$gte = filters.minAge;
    if (filters.maxAge) conditions.age.$lte = filters.maxAge;
  }

  return await User.find(conditions)
    .sort({ createdAt: -1 })
    .limit(50)
    .all();
}

Pattern 2: Recent Activity

// Get recently active users
const recentUsers = await User.find({
  lastActive: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
})
  .sort({ lastActive: -1 })
  .limit(20)
  .all();

Pattern 3: Top Users

// Get top users by score
const topUsers = await User.find({ active: true })
  .sort({ score: -1 })
  .limit(10)
  .select(['name', 'score', 'avatar'])
  .all();

Pattern 4: Date Range Queries

// Users created in last 30 days
const recentUsers = await User.find({
  createdAt: {
    $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
  }
}).all();

// Users created in specific month
const januaryUsers = await User.find({
  createdAt: {
    $gte: new Date('2024-01-01'),
    $lt: new Date('2024-02-01')
  }
}).all();

Performance Tips

  • Use Indexes: Create indexes on frequently queried fields
  • Use Projections: Select only needed fields
  • Always Limit: Never fetch all documents without limit
  • Use Lean Queries: For read-only operations
  • Filter Early: Apply filters before sorting/limiting
  • Use Compound Indexes: For multi-field queries
  • Avoid N+1 Queries: Batch fetch related data
  • Cache Results: Cache frequently accessed queries

Query Examples

Example 1: User Search

async function searchUsers(searchTerm: string) {
  return await User.find({
    $or: [
      { name: { $regex: searchTerm, $options: 'i' } },
      { email: { $regex: searchTerm, $options: 'i' } }
    ],
    active: true
  })
    .select(['name', 'email', 'avatar'])
    .sort({ score: -1 })
    .limit(20)
    .all();
}

Example 2: Filtered List

async function getFilteredUsers(filters: {
  role?: string;
  minScore?: number;
  verified?: boolean;
}) {
  const conditions: any = { active: true };

  if (filters.role) {
    conditions.role = filters.role;
  }

  if (filters.minScore) {
    conditions.score = { $gte: filters.minScore };
  }

  if (filters.verified !== undefined) {
    conditions.verified = filters.verified;
  }

  return await User.find(conditions)
    .sort({ createdAt: -1 })
    .limit(50)
    .all();
}

Example 3: Complex Query

// Find active premium users with high scores created in last year
const users = await User.find({
  active: true,
  role: 'premium',
  score: { $gte: 1000 },
  createdAt: { $gte: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) },
  email: { $exists: true, $regex: /@/ },
  $or: [
    { verified: true },
    { posts: { $size: { $gte: 10 } } }
  ]
})
  .select(['name', 'email', 'score', 'avatar'])
  .sort({ score: -1, createdAt: -1 })
  .limit(100)
  .all();
📚 API Reference: For complete API documentation including all methods and TypeScript types, see Query Module API Reference.
Next: Learn about Aggregation for calculations and statistics.