class CreateTags < ActiveRecord::Migration[7.0]
def change
create_table :tags do |t|
t.string :name, null: false
t.integer :taggings_count, null: false, default: 0
t.timestamps
end
execute <<~SQL
CREATE UNIQUE INDEX index_tags_on_lower_name
ON tags (LOWER(name));
SQL
create_table :taggings do |t|
t.references :post, null: false, foreign_key: true
t.references :tag, null: false, foreign_key: true
t.timestamps
end
add_index :taggings, [:post_id, :tag_id], unique: true
end
end
class Tag < ApplicationRecord
has_many :taggings, dependent: :destroy
has_many :posts, through: :taggings
validates :name, presence: true, uniqueness: { case_sensitive: false }
before_validation :canonicalize!
def self.canonicalize(raw)
raw.to_s.strip.gsub(/\s+/, " ").downcase
end
def self.for_name(raw)
name = canonicalize(raw)
return nil if name.blank?
find_or_create_by(name: name)
rescue ActiveRecord::RecordNotUnique
find_by("LOWER(name) = ?", name)
end
private
def canonicalize!
self.name = self.class.canonicalize(name)
end
end
class Post < ApplicationRecord
has_many :taggings, dependent: :destroy
has_many :tags, through: :taggings
before_save :sync_tags, if: -> { @tag_names }
def tag_list
tags.map(&:name).join(", ")
end
def tag_list=(value)
@tag_names = value.to_s
.split(",")
.map { |token| Tag.canonicalize(token) }
.reject(&:blank?)
.uniq
end
private
def sync_tags
self.tags = @tag_names.filter_map { |name| Tag.for_name(name) }
@tag_names = nil
end
end
This snippet shows a common Rails pattern for keeping a free-form tag input clean by normalizing tags at write time rather than at read time. The core idea is that user-supplied tag strings are messy — mixed case, stray whitespace, duplicates, empty tokens — and normalizing them once on save keeps the stored data canonical so that queries, uniqueness constraints, and counts all behave predictably.
The create_tags migration establishes the storage. A tags table holds a canonical name with a case-insensitive unique index built via LOWER(name), which guarantees that Ruby and ruby can never both exist. A taggings join table wires posts to tags with a compound unique index so the same tag cannot be attached twice to one record. Enforcing invariants in the database, not just in Ruby, is what makes the normalization trustworthy under concurrency.
In Tag model, the class method Tag.canonicalize centralizes the string cleanup: it strips, collapses internal whitespace, and downcases. Tag.for_name uses this together with find_or_create_by so lookups and inserts always go through the same canonical form. before_validation calling canonicalize! ensures even direct writes are normalized before validation runs.
Post model exposes the write-time behavior developers actually touch. The virtual attribute tag_list= accepts a comma-separated string, splits it, canonicalizes each token, and rejects blanks with reject(&:blank?). The uniq call deduplicates after normalization, so Rails, rails , RAILS collapses to a single tag. The before_save hook sync_tags resolves each name to a Tag via for_name and assigns the association, letting Rails manage the join rows.
A key trade-off is that write-time normalization pushes cost onto saves and makes the raw input unrecoverable — the original casing is discarded by design. The pitfall to watch is a race where two requests create the same tag simultaneously; the LOWER(name) unique index turns that into a catchable RecordNotUnique rather than duplicate rows. This approach is worth reaching for whenever tags are queried far more often than they are written, since it moves the messy work out of the hot read path.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.