class AddMetadataToUsers < ActiveRecord::Migration[6.1]
def change
add_column :users, :metadata, :jsonb, default: {}, null: false
add_index :users, :metadata, using: :gin
end
end
class User < ApplicationRecord
store_accessor :metadata, :theme, :timezone, :email_notifications, :beta_features
# Query users with specific metadata values
scope :with_beta_features, -> { where("metadata @> ?", { beta_features: true }.to_json) }
scope :in_timezone, ->(tz) { where("metadata->>'timezone' = ?", tz) }
# Default values
after_initialize do
self.metadata ||= {}
self.theme ||= 'light'
self.timezone ||= 'UTC'
self.email_notifications = true if email_notifications.nil?
end
end
module Api
module V1
class UserPreferencesController < BaseController
before_action :authenticate_user!
def update
current_user.update!(preference_params)
render json: { metadata: current_user.metadata }, status: :ok
end
private
def preference_params
params.require(:preferences).permit(:theme, :timezone, :email_notifications, beta_features: [])
end
end
end
end
PostgreSQL's jsonb columns provide schema flexibility for semi-structured data without sacrificing query performance. I use JSON columns for user preferences, feature flags, or metadata that varies by record type. Unlike traditional EAV patterns, jsonb supports indexing and efficient queries via GIN indexes and operators like @> (contains) or -> (extract). The store_accessor method creates virtual attributes that feel like regular columns while being stored in the JSON field. This approach balances structure and flexibility—core attributes remain as columns for performance and integrity, while optional or variable attributes live in JSON. The downside is reduced type safety and schema enforcement compared to dedicated columns.