dynamo-query-engine
v1.0.9
Published
Type-safe DynamoDB query builder for GraphQL with support for filtering, sorting, pagination, and relation expansion
Maintainers
Readme
DynamoDB Query Engine
Type-safe DynamoDB query builder for GraphQL with support for filtering, sorting, pagination, and relation expansion. Built on top of Dynamoose for seamless integration with AWS Lambda and serverless applications.
Features
- DynamoDB-native: Only keys can be filtered/sorted (following DynamoDB best practices)
- Cursor-based pagination: Efficient pagination with query hash validation
- Type-safe: JSDoc type definitions for excellent IntelliSense support
- Expand support: Declarative relation expansion with configurable limits
- Zero external config: All metadata defined in your Dynamoose schemas
- AWS Lambda ready: Optimized for serverless environments
- GraphQL integration: Works seamlessly with GraphQL resolvers
Installation
npm install dynamo-query-enginePeer Dependencies
npm install dynamoose graphql graphql-parse-resolve-infoQuick Start
1. Define Models with Grid Configuration
Note: The library supports any hash key field name (e.g.,
pk,tenantId,userId, etc.). It automatically detects the hash key from your schema.
import dynamoose from "dynamoose";
const UserSchema = new dynamoose.Schema({
pk: {
type: String,
hashKey: true,
},
createdAt: {
type: String,
rangeKey: true,
query: {
sort: { type: "key" },
filter: {
type: "key",
operators: ["between", "gte", "lte"],
},
},
},
status: {
type: String,
index: {
name: "status-createdAt-index",
global: true,
rangeKey: "createdAt",
},
query: {
filter: {
type: "key",
operators: ["eq"],
index: "status-createdAt-index",
},
},
},
teamIds: {
type: Array,
schema: [String],
query: {
expand: {
type: "Team",
relation: "MANY",
defaultLimit: 5,
maxLimit: 20,
},
},
},
});
export const UserModel = dynamoose.model("Users", UserSchema);Example with custom hash key name:
// The library works with any hash key field name
const ReservationSchema = new dynamoose.Schema({
tenantId: {
type: String,
hashKey: true, // Automatically detected by getHashKey()
},
reservationId: {
type: String,
rangeKey: true, // Automatically detected by getRangeKey()
},
// ... other fields
});
export const ReservationModel = dynamoose.model("Reservations", ReservationSchema);Extracting keys programmatically:
import { getHashKey, getRangeKey } from "dynamo-query-engine";
// Get the hash key field name from your schema
const hashKeyField = getHashKey(ReservationModel.schema);
console.log(hashKeyField); // "tenantId"
// Get the range key field name (or null if none exists)
const rangeKeyField = getRangeKey(ReservationModel.schema);
console.log(rangeKeyField); // "reservationId"2. Register Models
import { modelRegistry } from "dynamo-query-engine";
import { UserModel } from "./models/user.model.js";
import { TeamModel } from "./models/team.model.js";
modelRegistry.register("User", UserModel);
modelRegistry.register("Team", TeamModel);3. Use in Lambda Resolver
import {
buildGridQuery,
resolveExpands,
encodeCursor
} from "dynamo-query-engine";
import { parseResolveInfo } from "graphql-parse-resolve-info";
export const handler = async (event) => {
const args = event.arguments;
// Build query
const { query, queryHash } = buildGridQuery({
model: UserModel,
partitionKeyValue: "ORG#123",
filterModel: args.filterModel,
sortModel: args.sortModel,
paginationModel: args.paginationModel,
cursor: args.cursor,
});
// Execute
const users = await query.exec();
// Resolve relations
const parsed = parseResolveInfo(event.info);
await resolveExpands(users, UserModel, parsed?.fieldsByTypeName);
// Return with cursor
return {
rows: users,
nextCursor: users.lastKey
? encodeCursor(users.lastKey, queryHash)
: null,
};
};4. Query from Client
query UsersGrid(
$paginationModel: PaginationInput!
$sortModel: [SortInput!]
$filterModel: FilterModelInput
$cursor: String
) {
usersGrid(
paginationModel: $paginationModel
sortModel: $sortModel
filterModel: $filterModel
cursor: $cursor
) {
rows {
firstName
lastName
email
teamIds {
name
status
}
}
nextCursor
}
}{
paginationModel: { pageSize: 20 },
sortModel: [{ field: "createdAt", sort: "desc" }],
filterModel: {
items: [
{ field: "status", operator: "eq", value: "active" }
]
}
}Grid Configuration Options
Filter Configuration
query: {
filter: {
type: "key", // Only "key" supported
operators: ["eq", "gte", "lte"], // Allowed operators
index: "status-index" // Optional GSI
}
}Supported Operators: eq, ne, gt, gte, lt, lte, between, beginsWith, contains
Sort Configuration
query: {
sort: { type: "key" } // Only range keys can be sorted
}Note: DynamoDB only supports sorting by one field (the range key).
Expand Configuration
query: {
expand: {
type: "Team", // Model name (must be registered)
relation: "MANY", // "ONE" or "MANY"
defaultLimit: 5, // Default fetch limit
maxLimit: 20 // Maximum allowed limit
}
}API Reference
Core Functions
buildGridQuery(options)
Builds a DynamoDB query from grid parameters.
Parameters:
model- Dynamoose modelpartitionKeyValue- Partition key valuefilterModel- Filter configuration (optional)sortModel- Sort configuration (optional)paginationModel- Pagination configuration (required)cursor- Pagination cursor (optional)
Returns: { query, queryHash }
resolveExpand(options)
Resolves a single relation expansion.
Parameters:
parentItems- Array of parent itemsparentModel- Parent Dynamoose modelfield- Field name to expandargs- Expansion arguments (filter, sort, pagination)
resolveExpands(parentItems, parentModel, fieldsByTypeName)
Resolves multiple expansions from GraphQL resolve info.
Parameters:
parentItems- Array of parent itemsparentModel- Parent Dynamoose modelfieldsByTypeName- Parsed GraphQL resolve info fields
Utilities
encodeCursor(lastKey, queryHash)
Encodes pagination cursor to Base64.
decodeCursor(cursor)
Decodes Base64 cursor.
modelRegistry.register(name, model)
Registers a Dynamoose model.
modelRegistry.get(name)
Retrieves a registered model.
getHashKey(schema)
Extracts the hash key (partition key) field name from a Dynamoose schema.
Returns: string - The hash key field name
Throws: Error if no hash key is found in the schema
getRangeKey(schema)
Extracts the range key (sort key) field name from a Dynamoose schema.
Returns: string | null - The range key field name, or null if no range key exists
Architecture
┌─────────────┐
│ GraphQL │
│ Query │
└──────┬──────┘
│
▼
┌─────────────┐
│ Lambda │
│ Resolver │
└──────┬──────┘
│
├──────► buildGridQuery() ──► DynamoDB
│ │
│ ▼
│ ┌──────┐
│ │ Items│
│ └───┬──┘
│ │
└──────► resolveExpands() ◄────┘
│
├──► ModelRegistry
│
└──► buildGridQuery() ──► DynamoDB (nested)Examples
Check out the examples directory for complete working examples:
- User Model - Model with expand configuration
- Team Model - Model with filter/sort configuration
- Lambda Resolver - Complete resolver implementation
- GraphQL Queries - Query examples and variables
See the examples README for detailed documentation.
Best Practices
1. Use Global Secondary Indexes for Filters
status: {
type: String,
index: {
name: "status-createdAt-index",
global: true,
rangeKey: "createdAt",
},
query: {
filter: {
type: "key",
operators: ["eq"],
index: "status-createdAt-index", // Use the GSI
},
},
}2. Validate User Input
if (args.paginationModel.pageSize > 100) {
throw new Error("Page size cannot exceed 100");
}3. Handle Errors Gracefully
try {
const { query } = buildGridQuery({...});
return await query.exec();
} catch (error) {
console.error("Query failed:", error);
throw new Error(`Failed to fetch data: ${error.message}`);
}4. Limit Expand Depth
Don't allow deeply nested expansions as they can cause performance issues and high DynamoDB costs.
5. Monitor DynamoDB Costs
Each expand creates additional queries. Use CloudWatch to monitor your read capacity units.
DynamoDB Limitations
This library respects DynamoDB's constraints:
- Only key attributes (hash key, range key, or GSI keys) can be filtered
- Only range keys can be sorted
- Only one sort field is allowed per query
- No attribute-level filters (use scan sparingly for those cases)
These limitations ensure efficient queries and predictable performance.
Error Handling
The library provides descriptive errors:
"Filtering not allowed on field 'X'"- Field doesn't have filter config"Sorting not allowed on field 'X'"- Field doesn't have sort config"Operator 'X' not allowed for field 'Y'"- Operator not in allowed list"Only one sort field supported"- Multiple sort fields specified"Cursor does not match query"- Query params changed between pages"Model 'X' not found in registry"- Model not registered
Testing
The package includes comprehensive unit tests with Vitest.
Run Tests
# Run all tests
npm test
# Run tests with UI
npm run test:ui
# Run tests once (CI mode)
npm run test:run
# Generate coverage report
npm run coverageTest Coverage
- Cursor Utils: ~80% coverage
- Validation: ~70% coverage
- GridQueryBuilder: ~50% coverage
- Overall: ~50-60% coverage
See tests/README.md for detailed testing documentation.
Contributing
Contributions are welcome! Please open an issue or pull request.
Development Setup
# Install dependencies
npm install
# Run tests
npm test
# Check coverage
npm run coverageLicense
MIT © Sascha Eckstein
