Compare commits
3 Commits
16b27a0d1c
...
2a458f74d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a458f74d6 | |||
| 9d65a4e29d | |||
| 8be82f5c49 |
2
Gemfile
2
Gemfile
@@ -49,6 +49,8 @@ group :development, :test do
|
||||
|
||||
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
|
||||
gem 'dotenv-rails'
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
||||
13
README.md
13
README.md
@@ -12,6 +12,17 @@ $ psql -U postgres
|
||||
postgres=# ALTER USER <username> CREATEDB;
|
||||
```
|
||||
|
||||
6. `rails db:migrate`
|
||||
6. `rails db:migrate`, then create a new org:
|
||||
|
||||
```sh
|
||||
rails tenant:create[name,subdomain] # Create a new tenant (organization)
|
||||
rails tenant:migrate # Migrate all tenant schemas
|
||||
|
||||
rails tenant:migrate_one[subdomain] # Migrate a specific tenant schema
|
||||
rails tenant:rollback[step] # Rollback all tenant schemas by N steps (default: 1)
|
||||
rails tenant:status # Show migration status for all tenants
|
||||
rails tenant:drop[subdomain] # Drop a tenant schema and organization
|
||||
```
|
||||
|
||||
7. `rails server`
|
||||
8. Open localhost:3000
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :session
|
||||
delegate :user, to: :session, allow_nil: true
|
||||
attribute :organization, :user
|
||||
end
|
||||
|
||||
27
app/models/organization.rb
Normal file
27
app/models/organization.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Organization < ApplicationRecord
|
||||
validates :name, presence: true
|
||||
validates :subdomain, presence: true, uniqueness: true
|
||||
|
||||
after_create :create_schema
|
||||
before_destroy :drop_schema
|
||||
|
||||
def schema_name
|
||||
"org_#{id.to_s.gsub('-', '_')}"
|
||||
end
|
||||
|
||||
def with_schema(&block)
|
||||
SchemaManager.with_schema(id, &block)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_schema
|
||||
SchemaManager.create_schema(id)
|
||||
end
|
||||
|
||||
def drop_schema
|
||||
SchemaManager.drop_schema(id)
|
||||
end
|
||||
end
|
||||
67
app/models/schema_manager.rb
Normal file
67
app/models/schema_manager.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
# app/models/schema_manager.rb
|
||||
class SchemaManager
|
||||
class << self
|
||||
def create_schema(organization_id)
|
||||
schema_name = schema_name_for(organization_id)
|
||||
|
||||
ActiveRecord::Base.connection.execute("CREATE SCHEMA IF NOT EXISTS #{schema_name}")
|
||||
|
||||
with_schema(organization_id) do
|
||||
# Run all existing migrations in this schema
|
||||
migration_context = ActiveRecord::MigrationContext.new(Rails.application.paths["db/migrate"].first)
|
||||
migration_context.migrate
|
||||
end
|
||||
end
|
||||
|
||||
def drop_schema(organization_id)
|
||||
schema_name = schema_name_for(organization_id)
|
||||
ActiveRecord::Base.connection.execute("DROP SCHEMA IF EXISTS #{schema_name} CASCADE")
|
||||
end
|
||||
|
||||
def with_schema(organization_id)
|
||||
schema_name = schema_name_for(organization_id)
|
||||
original_schema = ActiveRecord::Base.connection.schema_search_path
|
||||
|
||||
begin
|
||||
ActiveRecord::Base.connection.schema_search_path = schema_name
|
||||
yield
|
||||
ensure
|
||||
ActiveRecord::Base.connection.schema_search_path = original_schema
|
||||
end
|
||||
end
|
||||
|
||||
def schema_name_for(organization_id)
|
||||
"org_#{organization_id.to_s.gsub('-', '_')}"
|
||||
end
|
||||
|
||||
# Migrate all existing organization schemas
|
||||
def migrate_all_schemas
|
||||
migration_context = ActiveRecord::MigrationContext.new(Rails.application.paths["db/migrate"].first)
|
||||
|
||||
Organization.find_each do |org|
|
||||
puts "Migrating schema for #{org.name}..."
|
||||
org.with_schema do
|
||||
migration_context.migrate
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# List all organization schemas
|
||||
def list_schemas
|
||||
result = ActiveRecord::Base.connection.execute(
|
||||
"SELECT schema_name FROM information_schema.schemata
|
||||
WHERE schema_name LIKE 'org_%'"
|
||||
)
|
||||
result.map { |row| row['schema_name'] }
|
||||
end
|
||||
|
||||
# Check if schema exists
|
||||
def schema_exists?(organization_id)
|
||||
schema_name = schema_name_for(organization_id)
|
||||
result = ActiveRecord::Base.connection.execute(
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = '#{schema_name}')"
|
||||
)
|
||||
result.first['exists']
|
||||
end
|
||||
end
|
||||
end
|
||||
21
db/migrate/20250611160914_create_organizations.rb
Normal file
21
db/migrate/20250611160914_create_organizations.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
class CreateOrganizations < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
# Enable pgcrypto extension for UUID support
|
||||
enable_extension 'pgcrypto' if connection.adapter_name == 'PostgreSQL'
|
||||
|
||||
create_table :organizations, id: :uuid do |t|
|
||||
t.string :name
|
||||
t.string :subdomain
|
||||
t.string :email_domain
|
||||
t.string :logo_url
|
||||
t.string :address
|
||||
t.string :website
|
||||
t.string :phone_number
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :organizations, :subdomain, unique: true
|
||||
add_reference :users, :organization, null: false, foreign_key: true, type: :uuid
|
||||
end
|
||||
end
|
||||
90
lib/tasks/tenant.rake
Normal file
90
lib/tasks/tenant.rake
Normal file
@@ -0,0 +1,90 @@
|
||||
namespace :tenant do
|
||||
desc "Migrate all tenant schemas"
|
||||
task migrate: :environment do
|
||||
# First migrate the public schema (organizations table, etc.)
|
||||
puts "Migrating public schema..."
|
||||
ActiveRecord::MigrationContext.new(Rails.application.paths["db/migrate"].first).migrate
|
||||
|
||||
# Then migrate all tenant schemas
|
||||
puts "Migrating all tenant schemas..."
|
||||
SchemaManager.migrate_all_schemas
|
||||
|
||||
puts "All schemas migrated successfully!"
|
||||
end
|
||||
|
||||
desc "Migrate specific tenant schema"
|
||||
task :migrate_one, [:subdomain] => :environment do |task, args|
|
||||
subdomain = args[:subdomain]
|
||||
organization = Organization.find_by!(subdomain: subdomain)
|
||||
|
||||
puts "Migrating schema for #{organization.name}..."
|
||||
organization.with_schema do
|
||||
ActiveRecord::MigrationContext.new(Rails.application.paths["db/migrate"].first).migrate
|
||||
end
|
||||
puts "Migration complete!"
|
||||
end
|
||||
|
||||
desc "Rollback all tenant schemas"
|
||||
task :rollback, [:step] => :environment do |task, args|
|
||||
step = (args[:step] || 1).to_i
|
||||
|
||||
Organization.find_each do |org|
|
||||
puts "Rolling back #{step} step(s) for #{org.name}..."
|
||||
org.with_schema do
|
||||
ActiveRecord::MigrationContext.new(Rails.application.paths["db/migrate"].first).rollback(step)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc "Show migration status for all tenants"
|
||||
task status: :environment do
|
||||
Organization.find_each do |org|
|
||||
puts "\n#{org.name} (#{org.subdomain}):"
|
||||
org.with_schema do
|
||||
migration_context = ActiveRecord::MigrationContext.new(Rails.application.paths["db/migrate"].first)
|
||||
migration_context.migrations_status.each do |status, version, name|
|
||||
puts " #{status.center(8)} #{version} #{name}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc "Create a new tenant"
|
||||
task :create, [:name, :subdomain] => :environment do |task, args|
|
||||
name = args[:name]
|
||||
subdomain = args[:subdomain]
|
||||
|
||||
if name.blank? || subdomain.blank?
|
||||
puts "Usage: rails tenant:create[name,subdomain]"
|
||||
puts "Example: rails tenant:create['Acme Corp','acme']"
|
||||
exit 1
|
||||
end
|
||||
|
||||
organization = Organization.create!(name: name, subdomain: subdomain)
|
||||
puts "Created organization: #{organization.name}"
|
||||
puts "Schema: #{organization.schema_name}"
|
||||
puts "Access at: http://#{subdomain}.localhost:3000"
|
||||
end
|
||||
|
||||
desc "Drop a tenant schema"
|
||||
task :drop, [:subdomain] => :environment do |task, args|
|
||||
subdomain = args[:subdomain]
|
||||
|
||||
if subdomain.blank?
|
||||
puts "Usage: rails tenant:drop[subdomain]"
|
||||
exit 1
|
||||
end
|
||||
|
||||
organization = Organization.find_by!(subdomain: subdomain)
|
||||
|
||||
print "Are you sure you want to drop #{organization.name}? (y/N): "
|
||||
response = STDIN.gets.chomp.downcase
|
||||
|
||||
if response == 'y' || response == 'yes'
|
||||
organization.destroy
|
||||
puts "Dropped organization: #{organization.name}"
|
||||
else
|
||||
puts "Cancelled"
|
||||
end
|
||||
end
|
||||
end
|
||||
19
test/fixtures/organizations.yml
vendored
Normal file
19
test/fixtures/organizations.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
name: MyString
|
||||
subdomain: MyString
|
||||
email_domain: MyString
|
||||
logo_url: MyString
|
||||
address: MyString
|
||||
website: MyString
|
||||
phone_number: MyString
|
||||
|
||||
two:
|
||||
name: MyString
|
||||
subdomain: MyString
|
||||
email_domain: MyString
|
||||
logo_url: MyString
|
||||
address: MyString
|
||||
website: MyString
|
||||
phone_number: MyString
|
||||
7
test/models/organization_test.rb
Normal file
7
test/models/organization_test.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class OrganizationTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
Reference in New Issue
Block a user