Mongory-rb
A Mongo-like in-memory query DSL for Ruby.
Mongory lets you filter and query in-memory collections using syntax and semantics similar to MongoDB. It is designed for expressive chaining, symbolic operators, and composable matchers.
Positioning
Mongory is designed to serve two types of users:
For MongoDB users:
- Seamless integration with familiar query syntax
- Extends query capabilities for non-indexed fields
- No additional learning cost
For non-MongoDB users:
- Initial learning cost for MongoDB-style syntax
- Long-term benefits:
- Improved code readability
- Better development efficiency
- Lower maintenance costs
- Ideal for teams valuing code quality and maintainability
Requirements
- Ruby >= 2.6.0
- No external database required
Quick Start
Installation
Rails Generator
You can install a starter configuration with:
rails g mongory:install
This will generate config/initializers/mongory.rb
and set up:
- Optional symbol operator snippets (e.g.
:age.gt => 18
) - Class registration (e.g.
Array
,ActiveRecord::Relation
, etc.) - Custom value/key converters for your ORM
Or install manually:
gem install mongory
Or add to your Gemfile:
gem 'mongory'
Basic Usage
records = [
{ 'name' => 'Jack', 'age' => 18, 'gender' => 'M' },
{ 'name' => 'Jill', 'age' => 15, 'gender' => 'F' },
{ 'name' => 'Bob', 'age' => 21, 'gender' => 'M' },
{ 'name' => 'Mary', 'age' => 18, 'gender' => 'F' }
]
# Basic query with conditions
result = records.mongory
.where(:age.gte => 18)
.or({ :name => /J/ }, { :name.eq => 'Bob' })
# Using limit to restrict results
# Note: limit executes immediately and affects subsequent conditions
limited = records.mongory
.limit(2) # Only process first 2 records
.where(:age.gte => 18) # Conditions apply to limited set
Integration with MongoDB
Mongory is designed to complement MongoDB, not replace it. Here's how to use them together:
Use MongoDB for:
- Queries with indexes
- Persistent data operations
- Large-scale data processing
Use Mongory for:
- Queries without indexes
- Complex in-memory calculations
- Temporary data filtering needs
Example:
# First use MongoDB for indexed queries
users = User.where(status: 'active') # Uses MongoDB index
# Then use Mongory for non-indexed fields
active_users = users.mongory
.where(:last_login.gte => 1.week.ago) # No index on last_login
.where(:tags.elem_match => { :name => 'ruby' }) # Complex array query
Creating Custom Matchers
Using the Generator
You can generate a new matcher using:
rails g mongory:matcher class_in
This will:
- Create a new matcher file at
lib/mongory/matchers/class_in_matcher.rb
- Create a spec file at
spec/mongory/matchers/class_in_matcher_spec.rb
- Update
config/initializers/mongory.rb
to require the new matcher
The generated matcher will:
- Be named
ClassInMatcher
- Register the operator as
$classIn
- Be available as
:class_in
in queries
Example usage of the generated matcher:
records.mongory.where(:value.class_in => [Integer, String])
Manual Creation
If you prefer to create matchers manually, here's an example:
class ClassInMatcher < Mongory::Matchers::AbstractMatcher
def match(subject)
@condition.any? { |klass| subject.is_a?(klass) }
end
def check_validity!
raise TypeError, '$classIn needs an array.' unless @condition.is_a?(Array)
@condition.each do |klass|
raise TypeError, '$classIn needs an array of class.' unless klass.is_a?(Class)
end
end
end
Mongory::Matchers.register(:class_in, '$classIn', ClassInMatcher)
[{a: 1}].mongory.where(:a.class_in => [Integer]).first
# => { a: 1 }
You can define any matcher behavior and attach it to a $operator
of your choice.
Matchers can be composed, validated, and traced just like built-in ones.
Handling Dots in Field Names
Mongory supports field names containing dots, which require escaping:
# Sample data
records = [
{ "user.name" => "John", "age" => 25 }, # Field name contains a dot
{ "user" => { "name" => "Bob" }, "age" => 30 } # Nested field
]
# Field name contains a dot
records.mongory.where("user\\.name" => "John") # Two backslashes needed with double quotes
# => [{ "user.name" => "John", "age" => 25 }]
# or
records.mongory.where('user\.name' => "John") # One backslash needed with single quotes
# => [{ "user.name" => "John", "age" => 25 }]
# Nested field (no escaping needed)
records.mongory.where("user.name" => "Bob")
# => [{ "user" => { "name" => "Bob" }, "age" => 30 }]
Note:
- With double quotes, backslashes need to be escaped (
\\
) - With single quotes, backslashes don't need to be escaped (
\
) - This behavior is consistent with MongoDB's query syntax
- The escaped dot pattern (
\.
) matches fields where the dot is part of the field name - The unescaped dot pattern (
.
) matches nested fields in the document structure
Advanced Usage
Complex Queries
# Nested conditions
users.mongory
.where(
:age.gte => 18,
:$or => [
{ :status => 'active' },
{ :status => 'pending', :created_at.gte => 1.week.ago }
]
)
# Using any_of for nested OR conditions
users.mongory
.where(:age.gte => 18)
.any_of(
{ :status => 'active' },
{ :status => 'pending', :created_at.gte => 1.week.ago }
)
# Array operations
posts.mongory
.where(:tags.elem_match => { :name => 'ruby', :priority.gt => 5 })
.where(:comments.every => { :approved => true })
Integration with ActiveRecord
class User < ActiveRecord::Base
def active_friends
friends.mongory
.where(:status => 'active')
.where(:last_seen.gte => 1.day.ago)
end
end
Query API Reference
Registering Models
To allow calling .mongory
on collections, use register
:
Mongory.register(Array)
Mongory.register(ActiveRecord::Relation)
User.where(status: 'active').mongory.where(:age.gte => 18, :name.regex => "^S.+")
This injects a .mongory
method via an internal extension module.
Internally, the query is compiled into a matcher tree using the QueryMatcher
and ConditionConverter
.
Method | Description | Example |
---|---|---|
where |
Adds a condition to filter records | where(age: { :$gte => 18 }) |
not |
Adds a negated condition | not(age: { :$lt => 18 }) |
and |
Combines conditions with $and |
and({ age: { :$gte => 18 } }, { name: /J/ }) |
or |
Combines conditions with $or |
or({ age: { :$gte => 18 } }, { name: /J/ }) |
any_of |
Combines conditions with $or inside an $and block |
any_of({ age: { :$gte => 18 } }, { name: /J/ }) |
in |
Checks if a value is in a set | in(age: [18, 19, 20]) |
nin |
Checks if a value is not in a set | nin(age: [18, 19, 20]) |
limit |
Limits the number of records returned. This method executes immediately and affects subsequent conditions. | limit(2) |
pluck |
Extracts selected fields from matching records | pluck(:name) |
with_context |
Sets a custom context for the query. Useful for controlling data conversion and sharing configuration across matchers. | with_context(merchant: merchant) |
Context Configuration
The with_context
method allows you to customize the query execution environment:
# Share configuration across matchers
records.mongory
.with_context(custom_option: true)
.where(:status => 'active')
.where(:age.gte => 18)
This will share a mutatable, but stable context object to all matchers in matcher tree.
To get your custom option, using @context.config
in your custom matcher.
Debugging Queries
You can use explain
to visualize the matcher tree structure:
records = [
{ name: 'John', age: 25, status: 'active' },
{ name: 'Jane', age: 30, status: 'inactive' }
]
query = records.mongory
.where(:age.gte => 18)
.any_of(
{ :status => 'active' },
{ :name.regex => /^J/ }
)
query.explain
Output:
And: {"age"=>{"$gte"=>18}, "$or"=>[{"status"=>"active"}, {"name"=>{"$regex"=>/^J/}}]}
├─ Field: "age" to match: {"$gte"=>18}
│ └─ Gte: 18
└─ Or: [{"status"=>"active"}, {"name"=>{"$regex"=>/^J/}}]
├─ Field: "status" to match: "active"
│ └─ Eq: "active"
└─ Field: "name" to match: {"$regex"=>/^J/}
└─ Regex: /^J/
This helps you understand how your query is being processed and can be useful for debugging complex conditions.
Or use the debugger for detailed matching process:
# Enable debugging
Mongory.debugger.enable
# Execute your query
query = Mongory.build_query(users).where(age: { :$gt => 18 })
query.each do |user|
puts user
end
# Display the debug trace
Mongory.debugger.display
The debug output will show detailed matching process with full class names:
QueryMatcher Matched, condition: {"age"=>{"$gt"=>18}}, record: {"age"=>25}
AndMatcher Matched, condition: {"age"=>{"$gt"=>18}}, record: {"age"=>25}
FieldMatcher Matched, condition: {"$gt"=>18}, field: "age", record: {"age"=>25}
GtMatcher Matched, condition: 18, record: 25
The debug output includes:
- The matcher tree structure with full class names
- Each matcher's condition and record value
- Color-coded results (green for matched, red for mismatched, purple for errors)
- Field names highlighted in gray background
- Detailed matching process for each record
Performance Considerations
Memory Usage
- Mongory operates entirely in memory
- Consider your data size and memory constraints
- Proc-based implementation reduces memory usage
- Context system provides better memory management
Query Optimization
- Complex conditions are evaluated in sequence
- Use
explain
to analyze query performance - Empty conditions are optimized with cached Procs
- Context system allows fine-grained control over conversion
Benchmarks
# Simple query (1000 records) records.mongory.where(:age.gte => 18) # ~0.8ms # Complex query (1000 records) records.mongory.where(:$or => [{:age.gte => 18}, {:status => 'active'}]) # ~0.9ms # Simple query (10000 records) records.mongory.where(:age.gte => 18) # ~6.5ms # Complex query (10000 records) records.mongory.where(:$or => [{:age.gte => 18}, {:status => 'active'}]) # ~7.5ms # Simple query (100000 records) records.mongory.where(:age.gte => 18) # ~64.7ms # Complex query (100000 records) records.mongory.where(:$or => [{:age.gte => 18}, {:status => 'active'}]) # ~75.0ms # Complex query with fast mode (100000 records) records.mongory.where(:$or => [{:age.gte => 18}, {:status => 'active'}]).fast # ~42.8ms, about 3x of plain ruby
Note: Performance varies based on:
- Data size
- Query complexity
- Hardware specifications
- Ruby version
Test in your environment to determine if performance meets your needs.
Supported Operators
Category | Operators |
---|---|
Comparison | $eq , $ne , $gt , $gte , $lt , $lte |
Set | $in , $nin |
Boolean | $and , $or , $not |
Pattern | $regex |
Presence | $exists , $present |
Nested Match | $elemMatch , $every |
Note: Some operators are Mongory-specific and not available in MongoDB:
$present
: Checks if a field is considered "present" (not nil, not empty, not KEY_NOT_FOUND)- Similar to
$exists
but evaluates truthiness of the value - Example:
where(:name.present => true)
- Similar to
$every
: Checks if all elements in an array match the given condition- Similar to
$elemMatch
but requires all elements to match - Example:
where(:tags.every => { :priority.gt => 5 })
- Similar to
Example:
# $present: Check if field is present (not nil, not empty)
records.mongory.where(:name.present => true) # name is present
records.mongory.where(:name.present => false) # name is not present
# $every: Check if all array elements match condition
records.mongory.where(:tags.every => { :priority.gt => 5 }) # all tags have priority > 5
FAQ
Q: How does Mongory compare to MongoDB?
A: Mongory provides similar query syntax but operates entirely in memory. It's ideal for:
- Small to medium datasets
- Complex in-memory filtering
- Testing MongoDB-like queries without a database
Q: Can I use Mongory with large datasets?
A: Yes, but consider:
- Memory usage
- Query complexity
- Caching strategies
- Using
limit
early in the chain
Q: How do I handle errors?
begin
result = records.mongory.where(invalid: :condition)
rescue Mongory::Error => e
# Handle error
end
Troubleshooting
Debugging Queries
Mongory.debugger.enable records.mongory.where(:age => 18).to_a Mongory.debugger.display Mongory.debugger.disable
Common Issues
- Symbol snippets not working? Call
Mongory.enable_symbol_snippets!
- Complex queries slow? Use
explain
to analyze - Memory issues? Consider pagination or streaming
- Symbol snippets not working? Call
Best Practices
Query Composition
# Good: Use method chaining records.mongory .where(:age.gte => 18) .where(:status => 'active') .limit(10) # Bad: Avoid redundant query creation query = records.mongory.where(:age.gte => 18) query = query.where(:status => 'active') # Unnecessary
Performance Tips
# Use limit to restrict result set records.mongory.limit(100).where(:age.gte => 18) # Use fast mode for better performance records.mongory.where(:age.gte => 18).fast # Use explain to analyze complex queries query = records.mongory.where(:$or => [...]) query.explain
Code Organization
# Encapsulate common queries as methods class User def active_adults friends.mongory .where(:age.gte => 18) .where(:status => 'active') end end
Limitations
Data Size
- Suitable for small to medium datasets
- Large datasets may impact performance
- Proc-based implementation helps with memory usage
- Context system provides better resource management
Query Complexity
- Complex queries may affect performance
- Not all MongoDB operators are supported
- Proc-based implementation improves complex query performance
- Context system allows better control over query execution
Memory Usage
- All operations are performed in memory
- Consider memory constraints
Migration Guide
From Array#select
# Before records.select { |r| r['age'] >= 18 && r['status'] == 'active' } # After records.mongory.where(:age.gte => 18, status: 'active')
From ActiveRecord
# Before indexed_query.where("age >= ? AND status = ?", 18, 'active') # After indexed_query.mongory.where(:age.gte => 18, status: 'active')
From MongoDB
# Before (MongoDB) users.where(:age.gte => 18, status: 'active') # After (Mongory) users.mongory.where(:age.gte => 18, status: 'active') # Just the same.
Contributing
Contributions are welcome! Here's how you can help:
- Fork the repository.
- Create a new branch for each significant change.
- Write tests for your changes.
- Send a pull request.
Please ensure your code adheres to the project's style guide and that all tests pass before submitting.
Code of Conduct
Everyone interacting in the Mongory-rb project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the code of conduct.
License
MIT. See LICENSE file.