From 8be82f5c4981c9fae3512eb8ba3e8fbdec6046ba Mon Sep 17 00:00:00 2001 From: Niki Date: Wed, 11 Jun 2025 18:23:16 +0200 Subject: [PATCH] create org model and schema manager --- Gemfile | 2 + app/models/current.rb | 1 + app/models/organization.rb | 27 ++++++++ app/models/schema_manager.rb | 67 +++++++++++++++++++ .../20250611160914_create_organizations.rb | 21 ++++++ test/fixtures/organizations.yml | 19 ++++++ test/models/organization_test.rb | 7 ++ 7 files changed, 144 insertions(+) create mode 100644 app/models/organization.rb create mode 100644 app/models/schema_manager.rb create mode 100644 db/migrate/20250611160914_create_organizations.rb create mode 100644 test/fixtures/organizations.yml create mode 100644 test/models/organization_test.rb diff --git a/Gemfile b/Gemfile index 8fbbb3b..c22de90 100644 --- a/Gemfile +++ b/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 diff --git a/app/models/current.rb b/app/models/current.rb index 2bef56d..ee8a770 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,4 +1,5 @@ class Current < ActiveSupport::CurrentAttributes attribute :session delegate :user, to: :session, allow_nil: true + attribute :organization, :user end diff --git a/app/models/organization.rb b/app/models/organization.rb new file mode 100644 index 0000000..5e767ee --- /dev/null +++ b/app/models/organization.rb @@ -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 diff --git a/app/models/schema_manager.rb b/app/models/schema_manager.rb new file mode 100644 index 0000000..b584aab --- /dev/null +++ b/app/models/schema_manager.rb @@ -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 diff --git a/db/migrate/20250611160914_create_organizations.rb b/db/migrate/20250611160914_create_organizations.rb new file mode 100644 index 0000000..51a5b39 --- /dev/null +++ b/db/migrate/20250611160914_create_organizations.rb @@ -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 diff --git a/test/fixtures/organizations.yml b/test/fixtures/organizations.yml new file mode 100644 index 0000000..1428cbc --- /dev/null +++ b/test/fixtures/organizations.yml @@ -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 diff --git a/test/models/organization_test.rb b/test/models/organization_test.rb new file mode 100644 index 0000000..3f86f1c --- /dev/null +++ b/test/models/organization_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class OrganizationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end