Ruby Logo

Sequel ORM Basics

Master Sequel ORM with dataset operations, migrations, models, associations, and advanced features for elegant database programming.

Home Ruby Sequel ORM Basics

Sequel ORM Basics in Ruby

Introduction to Sequel

Sequel is a simple, flexible, and powerful SQL toolkit and Object-Relational Mapping (ORM) library for Ruby. It provides both a high-level ORM and a low-level database toolkit, making it suitable for everything from simple scripts to complex applications.

Installing Sequel

# Install Sequel
gem install sequel

# With database adapters
gem install sequel sqlite3 pg mysql2

# Add to Gemfile
gem 'sequel', '~> 5.0'
gem 'sqlite3'    # For SQLite
gem 'pg'         # For PostgreSQL
gem 'mysql2'     # For MySQL

bundle install

Basic Sequel Setup

require 'sequel'

# Connect to different databases
# SQLite
DB = Sequel.sqlite('myapp.db')

# PostgreSQL
# DB = Sequel.connect('postgres://user:password@localhost/myapp_development')

# MySQL
# DB = Sequel.connect('mysql2://user:password@localhost/myapp_development')

# Test connection
puts "Connected to #{DB.database_type} database"

# Enable logging
DB.loggers << Logger.new($stdout)

# Create a simple table
DB.create_table? :users do
  primary_key :id
  String :name, null: false
  String :email, null: false, unique: true
  Integer :age
  Boolean :active, default: true
  DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
  DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP
end

Dataset Operations

Sequel's dataset API provides a powerful and intuitive way to query databases using method chaining.

Basic Dataset Queries

require 'sequel'

DB = Sequel.sqlite('example.db')

# Get dataset for users table
users = DB[:users]

# Insert data
users.insert(name: 'Alice', email: 'alice@example.com', age: 30)
users.insert(name: 'Bob', email: 'bob@example.com', age: 25)
users.insert(name: 'Carol', email: 'carol@example.com', age: 35)

# Basic queries
puts "=== All Users ==="
users.all.each { |user| puts "#{user[:name]} (#{user[:age]})" }

puts "\n=== Active Users ==="
users.where(active: true).each { |user| puts user[:name] }

puts "\n=== Users over 25 ==="
users.where { age > 25 }.each { |user| puts "#{user[:name]} - #{user[:age]}" }

puts "\n=== Specific user ==="
alice = users.where(name: 'Alice').first
puts alice.inspect

# Count and aggregates
puts "\n=== Statistics ==="
puts "Total users: #{users.count}"
puts "Average age: #{users.avg(:age)}"
puts "Oldest user: #{users.max(:age)}"
puts "Youngest user: #{users.min(:age)}"

Advanced Dataset Operations

# Complex queries with method chaining
users = DB[:users]

# Multiple conditions
young_active_users = users
  .where(active: true)
  .where { age < 30 }
  .order(:name)

puts "Young active users:"
young_active_users.each { |user| puts user[:name] }

# Selecting specific columns
user_info = users
  .select(:name, :email)
  .where { age > 25 }
  .order(:name)

puts "\nUser info (name, email):"
user_info.each { |user| puts "#{user[:name]} - #{user[:email]}" }

# Limit and offset
paginated_users = users
  .order(:created_at)
  .limit(2)
  .offset(1)

puts "\nPaginated users (limit 2, offset 1):"
paginated_users.each { |user| puts user[:name] }

# Joins (create posts table first)
DB.create_table? :posts do
  primary_key :id
  foreign_key :user_id, :users
  String :title, null: false
  Text :content
  DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
end

posts = DB[:posts]
posts.insert(user_id: 1, title: 'First Post', content: 'Hello World!')
posts.insert(user_id: 1, title: 'Second Post', content: 'Learning Sequel')
posts.insert(user_id: 2, title: 'Bob Post', content: 'MySQL is great')

# Join users and posts
user_posts = users
  .join(:posts, user_id: :id)
  .select(
    Sequel[:users][:name].as(:user_name),
    Sequel[:posts][:title].as(:post_title),
    Sequel[:posts][:content]
  )

puts "\nUser posts:"
user_posts.each do |row|
  puts "#{row[:user_name]}: #{row[:post_title]}"
end

# Left join to include users without posts
all_users_posts = users
  .left_join(:posts, user_id: :id)
  .select(
    Sequel[:users][:name].as(:user_name),
    Sequel[:posts][:title].as(:post_title)
  )
  .order(Sequel[:users][:name])

puts "\nAll users with their posts (including users without posts):"
all_users_posts.each do |row|
  post_title = row[:post_title] || 'No posts'
  puts "#{row[:user_name]}: #{post_title}"
end

Dataset Modification

# Update records
users = DB[:users]

# Update single record
users.where(name: 'Alice').update(age: 31)

# Update multiple records
users.where { age < 30 }.update(active: false)

# Update with expressions
users.update(updated_at: Sequel::CURRENT_TIMESTAMP)

# Delete records
users.where(active: false).delete

# Bulk operations
new_users = [
  { name: 'David', email: 'david@example.com', age: 28 },
  { name: 'Eve', email: 'eve@example.com', age: 32 },
  { name: 'Frank', email: 'frank@example.com', age: 27 }
]

# Multi-insert
users.multi_insert(new_users)

# Import (more efficient for large datasets)
users.import([:name, :email, :age], [
  ['Grace', 'grace@example.com', 29],
  ['Henry', 'henry@example.com', 34],
  ['Ivy', 'ivy@example.com', 26]
])

puts "Users after bulk operations:"
users.order(:name).each { |user| puts "#{user[:name]} (#{user[:age]})" }

Sequel Models

Sequel models provide an object-oriented interface to database records with associations, validations, and hooks.

Basic Model Definition

require 'sequel'

DB = Sequel.sqlite('models_example.db')

# Enable model plugins
Sequel::Model.plugin :timestamps, update_on_create: true

class User < Sequel::Model
  # Table is automatically inferred as :users
  # You can specify explicitly: set_dataset :users

  # Associations
  one_to_many :posts
  one_to_many :comments

  # Validations
  def validate
    super
    errors.add(:name, 'cannot be empty') if !name || name.empty?
    errors.add(:email, 'cannot be empty') if !email || email.empty?
    errors.add(:email, 'invalid format') if email && !email.match(/@/)
    errors.add(:age, 'must be positive') if age && age <= 0
  end

  # Instance methods
  def adult?
    age && age >= 18
  end

  def display_name
    "#{name} <#{email}>"
  end

  # Hooks
  def before_create
    super
    self.created_at = Time.now
    self.updated_at = Time.now
  end

  def before_update
    super
    self.updated_at = Time.now
  end

  # Class methods
  def self.adults
    where { age >= 18 }
  end

  def self.by_name(name)
    where(name: name)
  end

  def self.active
    where(active: true)
  end
end

class Post < Sequel::Model
  many_to_one :user
  one_to_many :comments

  def validate
    super
    errors.add(:title, 'cannot be empty') if !title || title.empty?
    errors.add(:user_id, 'must be present') if !user_id
  end

  def summary(length = 100)
    return '' unless content
    content.length > length ? content[0..length] + '...' : content
  end
end

class Comment < Sequel::Model
  many_to_one :user
  many_to_one :post

  def validate
    super
    errors.add(:content, 'cannot be empty') if !content || content.empty?
    errors.add(:user_id, 'must be present') if !user_id
    errors.add(:post_id, 'must be present') if !post_id
  end
end

# Create tables
DB.create_table? :users do
  primary_key :id
  String :name, null: false
  String :email, null: false, unique: true
  Integer :age
  Boolean :active, default: true
  DateTime :created_at
  DateTime :updated_at
end

DB.create_table? :posts do
  primary_key :id
  foreign_key :user_id, :users, null: false
  String :title, null: false
  Text :content
  DateTime :created_at
  DateTime :updated_at
end

DB.create_table? :comments do
  primary_key :id
  foreign_key :user_id, :users, null: false
  foreign_key :post_id, :posts, null: false
  Text :content, null: false
  DateTime :created_at
  DateTime :updated_at
end

Working with Models

# Create users
alice = User.create(name: 'Alice', email: 'alice@example.com', age: 30)
bob = User.create(name: 'Bob', email: 'bob@example.com', age: 25)

puts "Created user: #{alice.display_name}"
puts "Is Alice an adult? #{alice.adult?}"

# Create posts
post1 = Post.create(
  user: alice,
  title: 'Getting Started with Sequel',
  content: 'Sequel is a powerful ORM for Ruby...'
)

post2 = Post.create(
  user_id: alice.id,
  title: 'Advanced Sequel Features',
  content: 'In this post, we will explore advanced features like associations, validations, and hooks.'
)

post3 = Post.create(
  user: bob,
  title: 'Database Design Patterns',
  content: 'Good database design is crucial for application performance...'
)

# Create comments
Comment.create(
  user: bob,
  post: post1,
  content: 'Great introduction! Looking forward to more posts.'
)

Comment.create(
  user: alice,
  post: post3,
  content: 'Thanks for sharing these patterns!'
)

# Query using models
puts "\n=== All Users ==="
User.all.each { |user| puts user.display_name }

puts "\n=== Adult Users ==="
User.adults.each { |user| puts "#{user.name} (#{user.age})" }

puts "\n=== Posts by Alice ==="
alice_posts = alice.posts
alice_posts.each { |post| puts "- #{post.title}" }

# Or query posts directly
alice_posts2 = Post.where(user: alice)
puts "Alice has #{alice_posts2.count} posts"

puts "\n=== Comments on Alice's first post ==="
post1.comments.each do |comment|
  puts "#{comment.user.name}: #{comment.content}"
end

# Complex queries
puts "\n=== Posts with Comments ==="
posts_with_comments = Post
  .join(:comments, post_id: :id)
  .select(Sequel[:posts][:title])
  .distinct

posts_with_comments.each { |post| puts "- #{post.title}" }

# Update models
alice.update(age: 31)
puts "\nAlice's updated age: #{alice.age}"

# Validation example
invalid_user = User.new(name: '', email: 'invalid-email')
if invalid_user.valid?
  invalid_user.save
else
  puts "\nValidation errors:"
  invalid_user.errors.full_messages.each { |msg| puts "- #{msg}" }
end

Migrations in Sequel

Sequel provides a robust migration system for managing database schema changes over time.

Creating Migrations

require 'sequel'

# Migration class
class CreateUsers < Sequel::Migration
  def up
    create_table :users do
      primary_key :id
      String :name, null: false
      String :email, null: false, unique: true
      Integer :age
      Boolean :active, default: true
      DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
      DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP

      index :email, unique: true
      index :active
    end
  end

  def down
    drop_table :users
  end
end

class CreatePosts < Sequel::Migration
  def up
    create_table :posts do
      primary_key :id
      foreign_key :user_id, :users, null: false, on_delete: :cascade
      String :title, null: false
      Text :content
      String :status, default: 'draft'
      Integer :view_count, default: 0
      DateTime :published_at
      DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
      DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP

      index :user_id
      index :status
      index :published_at
    end
  end

  def down
    drop_table :posts
  end
end

class AddCategoryToPosts < Sequel::Migration
  def up
    alter_table :posts do
      add_column :category, String, default: 'general'
      add_index :category
    end
  end

  def down
    alter_table :posts do
      drop_index :category
      drop_column :category
    end
  end
end

# Migration runner
class MigrationRunner
  def initialize(database)
    @db = database
    setup_migration_table
  end

  def run_migrations(migrations_dir = 'migrations')
    migration_files = Dir.glob(File.join(migrations_dir, '*.rb')).sort

    migration_files.each do |file|
      version = File.basename(file, '.rb')

      next if migration_applied?(version)

      puts "Running migration: #{version}"

      # Load migration file
      load file

      # Get migration class (assumes file name matches class name)
      migration_class = Object.const_get(version.split('_').map(&:capitalize).join)

      # Run migration
      @db.transaction do
        migration = migration_class.new
        migration.apply(@db, :up)
        record_migration(version)
      end

      puts "Migration #{version} completed"
    end
  end

  def rollback_migration(version)
    return unless migration_applied?(version)

    puts "Rolling back migration: #{version}"

    # Load migration file
    require_relative "migrations/#{version}.rb"
    migration_class = Object.const_get(version.split('_').map(&:capitalize).join)

    @db.transaction do
      migration = migration_class.new
      migration.apply(@db, :down)
      remove_migration_record(version)
    end

    puts "Migration #{version} rolled back"
  end

  def pending_migrations(migrations_dir = 'migrations')
    all_migrations = Dir.glob(File.join(migrations_dir, '*.rb'))
                        .map { |f| File.basename(f, '.rb') }
                        .sort

    applied_migrations = @db[:schema_migrations].select_map(:version)

    all_migrations - applied_migrations
  end

  private

  def setup_migration_table
    @db.create_table? :schema_migrations do
      String :version, primary_key: true
      DateTime :applied_at, default: Sequel::CURRENT_TIMESTAMP
    end
  end

  def migration_applied?(version)
    @db[:schema_migrations].where(version: version).count > 0
  end

  def record_migration(version)
    @db[:schema_migrations].insert(version: version)
  end

  def remove_migration_record(version)
    @db[:schema_migrations].where(version: version).delete
  end
end

# Usage example
DB = Sequel.sqlite('migration_example.db')

# Create migrations directory and files
require 'fileutils'
FileUtils.mkdir_p('migrations')

# Save migration files (you would normally create these as separate files)
File.write('migrations/001_create_users.rb', <<~RUBY)
  class CreateUsers < Sequel::Migration
    def up
      create_table :users do
        primary_key :id
        String :name, null: false
        String :email, null: false, unique: true
        Integer :age
        Boolean :active, default: true
        DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
      end
    end

    def down
      drop_table :users
    end
  end
RUBY

File.write('migrations/002_create_posts.rb', <<~RUBY)
  class CreatePosts < Sequel::Migration
    def up
      create_table :posts do
        primary_key :id
        foreign_key :user_id, :users, null: false
        String :title, null: false
        Text :content
        DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
      end
    end

    def down
      drop_table :posts
    end
  end
RUBY

# Run migrations
runner = MigrationRunner.new(DB)

puts "Pending migrations: #{runner.pending_migrations}"
runner.run_migrations

# Check applied migrations
applied = DB[:schema_migrations].all
puts "Applied migrations:"
applied.each { |m| puts "- #{m[:version]} (#{m[:applied_at]})" }

Advanced Sequel Features

Explore Sequel's advanced capabilities including plugins, transactions, and performance optimization.

Model Plugins and Extensions

require 'sequel'

DB = Sequel.sqlite(':memory:')

# Enable useful plugins globally
Sequel::Model.plugin :validation_helpers
Sequel::Model.plugin :timestamps
Sequel::Model.plugin :dirty
Sequel::Model.plugin :serialization

class User < Sequel::Model
  plugin :secure_password  # bcrypt integration

  # Serialization
  serialize_attributes :json, :preferences
  serialize_attributes :yaml, :settings

  # Validation helpers
  def validate
    super
    validates_presence [:name, :email]
    validates_unique :email
    validates_format /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\z/i, :email
    validates_integer :age, minimum: 0, maximum: 150
  end

  # Dirty tracking (tracks changes)
  def log_changes
    if changed_columns.any?
      puts "Changed columns: #{changed_columns}"
      changed_columns.each do |col|
        puts "  #{col}: #{initial_value(col)} -> #{self[col]}"
      end
    end
  end

  # Custom setters/getters
  def age=(value)
    super(value.to_i) if value
  end

  def preferences
    super || {}
  end

  def set_preference(key, value)
    prefs = preferences
    prefs[key.to_s] = value
    self.preferences = prefs
  end

  def get_preference(key)
    preferences[key.to_s]
  end
end

class Post < Sequel::Model
  plugin :tree  # Hierarchical data (parent/child relationships)
  plugin :list  # Ordered lists
  plugin :touch # Touch associated records

  many_to_one :user, touch: true
  many_to_one :parent, class: self
  one_to_many :children, key: :parent_id, class: self

  def validate
    super
    validates_presence [:title, :content, :user_id]
    validates_min_length 3, :title
    validates_min_length 10, :content
  end

  # Tree structure methods (from tree plugin)
  def ancestors_titles
    ancestors.map(&:title)
  end

  def descendants_count
    descendants.count
  end
end

# Create tables
DB.create_table :users do
  primary_key :id
  String :name, null: false
  String :email, null: false, unique: true
  String :password_digest
  Integer :age
  Text :preferences  # JSON serialization
  Text :settings     # YAML serialization
  DateTime :created_at
  DateTime :updated_at
end

DB.create_table :posts do
  primary_key :id
  foreign_key :user_id, :users, null: false
  foreign_key :parent_id, :posts  # For tree structure
  String :title, null: false
  Text :content, null: false
  Integer :position  # For list ordering
  DateTime :created_at
  DateTime :updated_at
end

# Usage examples
user = User.create(
  name: 'Alice',
  email: 'alice@example.com',
  password: 'secret123',
  age: 30
)

# Serialization
user.set_preference('theme', 'dark')
user.set_preference('notifications', true)
user.settings = { language: 'en', timezone: 'UTC' }
user.save

puts "User theme: #{user.get_preference('theme')}"
puts "User settings: #{user.settings}"

# Dirty tracking
user.name = 'Alice Johnson'
user.age = 31
user.log_changes
user.save

# Tree structure
parent_post = Post.create(
  user: user,
  title: 'Parent Post',
  content: 'This is a parent post with children'
)

child_post1 = Post.create(
  user: user,
  parent: parent_post,
  title: 'Child Post 1',
  content: 'This is the first child post'
)

child_post2 = Post.create(
  user: user,
  parent: parent_post,
  title: 'Child Post 2',
  content: 'This is the second child post'
)

puts "Parent post children: #{parent_post.children.map(&:title)}"
puts "Child post ancestors: #{child_post1.ancestors_titles}"

Transactions and Performance

require 'sequel'
require 'benchmark'

DB = Sequel.sqlite(':memory:')

# Create test table
DB.create_table :items do
  primary_key :id
  String :name
  Integer :value
  DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
end

items = DB[:items]

# Performance comparison: with and without transactions
puts "=== Performance Comparison ==="

# Without transaction (slow)
time_without = Benchmark.measure do
  1000.times do |i|
    items.insert(name: "Item #{i}", value: rand(100))
  end
end

# Clear table
items.delete

# With transaction (fast)
time_with = Benchmark.measure do
  DB.transaction do
    1000.times do |i|
      items.insert(name: "Item #{i}", value: rand(100))
    end
  end
end

puts "Without transaction: #{time_without.real.round(3)}s"
puts "With transaction: #{time_with.real.round(3)}s"
puts "Speed improvement: #{(time_without.real / time_with.real).round(1)}x faster"

# Advanced transaction features
puts "\n=== Transaction Features ==="

# Savepoints
DB.transaction(savepoint: true) do
  items.insert(name: 'Test 1', value: 10)

  DB.transaction(savepoint: true) do
    items.insert(name: 'Test 2', value: 20)

    # This savepoint will be rolled back
    DB.transaction(savepoint: true) do
      items.insert(name: 'Test 3', value: 30)
      raise Sequel::Rollback  # Rollback just this savepoint
    end

    puts "After inner rollback: #{items.where(name: /Test/).count} test items"
  end
end

# Transaction isolation levels
DB.transaction(isolation: :serializable) do
  # Perform operations that require serializable isolation
  critical_items = items.where { value > 50 }
  max_value = critical_items.max(:value)

  if max_value < 90
    items.insert(name: 'High Value Item', value: 95)
  end
end

# Optimistic locking example (requires timestamp column)
class OptimisticItem < Sequel::Model(:items)
  plugin :optimistic_locking

  # This would require a lock_version column in real usage
end

# Bulk operations for performance
puts "\n=== Bulk Operations ==="

# Multi-insert (efficient)
bulk_data = 100.times.map do |i|
  { name: "Bulk Item #{i}", value: rand(50) }
end

time_multi = Benchmark.measure do
  items.multi_insert(bulk_data)
end

puts "Multi-insert for 100 records: #{time_multi.real.round(3)}s"

# Import (even more efficient for large datasets)
import_data = 1000.times.map do |i|
  ["Import Item #{i}", rand(50)]
end

time_import = Benchmark.measure do
  items.import([:name, :value], import_data)
end

puts "Import for 1000 records: #{time_import.real.round(3)}s"

# Batch processing
puts "\n=== Batch Processing ==="

# Process records in batches to avoid memory issues
total_processed = 0

items.where { value > 25 }.paged_each(rows_per_fetch: 100) do |item|
  # Process each item
  total_processed += 1

  # Update item (example processing)
  items.where(id: item[:id]).update(value: item[:value] * 2)
end

puts "Processed #{total_processed} items in batches"

# Connection pooling for multi-threaded apps
puts "\n=== Connection Pooling ==="

# Configure connection pool
DB.pool.max_size = 10
DB.pool.timeout = 5

# Simulate concurrent operations
threads = []

5.times do |i|
  threads << Thread.new do
    DB.synchronize do |conn|
      result = conn[:items].where { value < 50 }.count
      puts "Thread #{i}: Found #{result} items with value < 50"
      sleep(0.1)  # Simulate work
    end
  end
end

threads.each(&:join)

# Pool statistics
puts "Connection pool size: #{DB.pool.size}"
puts "Connection pool max size: #{DB.pool.max_size}"

Sequel Best Practices

  • Use transactions for operations that need to be atomic
  • Leverage dataset method chaining for readable and efficient queries
  • Use prepared statements for frequently executed queries
  • Implement proper validations in model classes
  • Use associations to express relationships between models
  • Enable query logging during development for debugging
  • Use migrations to manage database schema changes
  • Take advantage of Sequel plugins for common functionality
  • Use bulk operations (multi_insert, import) for large datasets
  • Configure connection pooling appropriately for your application load

Quick Navigation

Related Topics

Video Tutorial

Watch and learn sequel orm basics

Pro Tip: After reading through the content above, watch this video to reinforce your understanding and see the concepts in action!