ActiveRecord::Ghosts 👻
"Virtual rows" for ActiveRecord models. Fill in the gaps in your sequences and work with ghost records that behave like AR objects but aren't persisted.
✨ Features
- Define a sequence column (like
level,number). - Query with a range and get real + ghost records.
- Chain with
where/ scopes. - Works with
Enumeratorfor infinite series. - Ghost records respond to
.ghost?.
🚀 Installation
Requirements:
- Ruby 3.4.0+
- Rails 7.2+
Add to your Gemfile:
gem "activerecord-ghosts"and run:
bundle install🏗 Setup
Database Schema
Your model needs an integer column for the sequence field. This column should be indexed for performance.
# Example migration
class CreateProgressLevels < ActiveRecord::Migration[7.2]
def change
create_table :progress_levels do |t|
t.references :user, null: false, foreign_key: true
t.integer :level, null: false # ← Ghost sequence column
t.integer :points, default: 0
t.timestamps
end
# Index for performance (IMPORTANT!)
add_index :progress_levels, [:user_id, :level], unique: true
# OR simple index if no associations:
# add_index :progress_levels, :level
end
endModel Configuration
class ProgressLevel < ApplicationRecord
belongs_to :user
# Enable ghosts on the integer sequence column
has_ghosts :level, start: 1 # Optional: custom start value (default: 1)
validates :level, presence: true, uniqueness: { scope: :user_id }
end
# Alternative start values:
class Invoice < ApplicationRecord
has_ghosts :number, start: 1000 # Start from 1000 instead of 1
end
# Usage with custom start:
Invoice.ghosts.take(3) # Will generate ghosts starting from 1000, 1001, 1002...Requirements:
- ✅ Ghost column must be integer type
- ✅ Ghost column should have an index (composite or simple)
- ✅ Index should have ghost column as leading column for best performance
🛠 Usage
1. Basic ghost series
# Assuming you have levels 1, 2, 5 in database
ProgressLevel.ghosts(1..6).map { |level| [level.level, level.ghost?] }
# => [[1, false], [2, false], [3, true], [4, true], [5, false], [6, true]]2. With associations and scoping
For a specific user:
user = User.find(1)
# Get levels 1-5 for this user (mix of real + ghost records)
user.progress_levels.ghosts(1..5).each do |level|
puts "Level #{level.level}: #{level.ghost? ? 'Missing' : 'Completed'} (#{level.points} points)"
end
# Output:
# Level 1: Completed (100 points)
# Level 2: Completed (150 points)
# Level 3: Missing (0 points) ← Ghost inherits default values
# Level 4: Missing (0 points) ← Ghost inherits default values
# Level 5: Missing (0 points) ← Ghost inherits default values
# Alternative syntax:
ProgressLevel.where(user_id: user.id).ghosts(1..5)Key insight: Ghost records automatically inherit where conditions and model defaults!
3. Infinite series
Without arguments, .ghosts returns an Enumerator that starts from the start value:
# With default start: 1
ProgressLevel.ghosts.take(3).map { |level| [level.level, level.ghost?] }
# => [[1, false], [2, false], [3, true]]
# With custom start: 1000
class Invoice < ApplicationRecord
has_ghosts :number, start: 1000
end
Invoice.ghosts.take(3).map { |inv| [inv.number, inv.ghost?] }
# => [[1000, true], [1001, true], [1002, true]] # Starts from 1000!You can each, each_slice, etc. Records are lazily loaded batch by batch.
📋 Supported Column Types & Limitations
✅ Supported Ghost Columns
-
Integer columns only -
t.integer :level,t.bigint :number, etc. - Must contain sequential numeric values (1, 2, 3... or 10, 20, 30...)
- Works with any integer range (
1..100,0..10,-5..5)
❌ Not Supported
- String columns (
t.string :name) - Date/DateTime columns (
t.date :created_on) - UUID columns (
t.uuid :external_id) - Non-sequential data
🎯 Perfect Use Cases
# ✅ Game levels (1, 2, 3, 4, 5...)
class PlayerLevel
has_ghosts :level # integer column
end
# ✅ Invoice numbers (1, 2, 3... or 1000, 1001, 1002...)
class Invoice
has_ghosts :number # integer column
end
# ✅ Chapter numbers in a book
class Chapter
has_ghosts :chapter_number # integer column
end
# ❌ Don't use for non-sequential data
class User
has_ghosts :email # ❌ String - won't work
has_ghosts :created_at # ❌ DateTime - not sequential
end⚠️ Performance note
For best performance, ensure the ghosted column (e.g. :level) has an index.
If it doesn't, you'll see a warning:
[activerecord-ghosts] ⚠️ Column :level on progress_levels has no leading index. Ghost queries may be slow.
Composite indexes are fine if your ghost column is the leading column.
❓ FAQ
Q: Can I use string/UUID columns as ghost fields?
A: No, only integer columns are supported. Ghost records fill numeric gaps in sequences.
Q: Do ghost records get saved to the database?
A: No! Ghost records exist only in memory. They behave like ActiveRecord objects but .persisted? returns false.
Q: Can I modify ghost records?
A: Yes! You can call .save! on a ghost record to persist it to the database. After saving, .ghost? will return false.
Q: How do I handle gaps in my sequence?
A: That's exactly what this gem does! It fills gaps with virtual records.
# You have records [1, 2, 5, 8] in database
Model.ghosts(1..10).map(&:id)
# Returns: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Where 3, 4, 6, 7, 9, 10 are ghostsQ: What about performance with large ranges?
A: Use the enumerator version for infinite sequences:
# ✅ Memory efficient - loads in batches
Model.ghosts.take(1000)
# ❌ Avoid large ranges - loads all at once
Model.ghosts(1..1000000)📦 Development
Clone and setup:
git clone https://github.com/ilyacoding/activerecord-ghosts
cd activerecord-ghosts
bundle install🚀 Development & Publishing
Local Development
bundle install
bundle exec rspecAutomated Publishing
This gem uses RubyGems.org Trusted Publishing for secure, automated releases.
Release Process:
- Update version in
lib/activerecord/ghosts/version.rb - Commit and create tag:
git tag v0.1.1 && git push --tags - GitHub Actions automatically publishes to RubyGems.org
Running Tests
bundle exec rspec📜 License
MIT © Ilya Kovalenko
🤝 Contributing
Pull requests welcome! By participating you agree to follow the Code of Conduct.