Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Support For Foreign Keys Across Schemas #397

Closed
robertjwhitney opened this issue Feb 3, 2017 · 22 comments
Closed

Add Support For Foreign Keys Across Schemas #397

robertjwhitney opened this issue Feb 3, 2017 · 22 comments

Comments

@robertjwhitney
Copy link

Is this on the roadmap at all? Postgres allows for it, I've seen a couple of issues but no real solutions, one of them even has a solution that doesn't seem to work in it. I'm guessing that it is a serious re-write, but having all users in one schema is probably pretty common, any thoughts?

@robertjwhitney
Copy link
Author

#382

@robertjwhitney
Copy link
Author

#227

@caironoleto
Copy link
Contributor

I think that isn't part of apartment. Postgres supports FKs across schemas.

@elct9620
Copy link

In my opinion, the apartment use migration generate SQL to implement multi-tenant feature. But the Rails will generate foreign keys when migration define foreign: true or use t.references.

So, the problem is migration contains foreign key SQL but not convert multi tenant syntax, and cause the migration errors.

I think this feature is still necessary, otherwise disable all foreign key in migrations.

@mikecmpbll
Copy link
Collaborator

i don't think rails supports things like cross-schema foreign keys or composite foreign keys. if you want to use these features i believe you'll have to drop to SQL in your migrations and use structure.sql.

i don't think this is an apartment responsibility, so closing for now.

@robertjwhitney
Copy link
Author

robertjwhitney commented Mar 3, 2017

@mikecmpbll #232 (comment)

@robertjwhitney
Copy link
Author

Frustrating to see all these closed, would you accept a pull request?

@mikecmpbll
Copy link
Collaborator

@robertjwhitney didn't mean to frustrate; just trying to do a clear up so we can see which are actualy issues. my impression, supported by your link, was that whatever solution for cross-schema foreign keys there is that it would need to be at the rails level (migration/schema).

if you believe there's something we can do in apartment, let me know and i'll reopen!

@Bongs
Copy link

Bongs commented May 14, 2017

Hey guys, I had opened rails issue for the cross-schema foreign key. It got closed with comment saying it matter of Apartment gem. Can someone please look into it - rails/rails#28654.

@mikecmpbll
Copy link
Collaborator

feel free to suggest a solution! :)

@romarioclacino
Copy link

Hey guys.
Am I facing this problem, any news?

@arturtr
Copy link

arturtr commented Jun 21, 2018

The solution found by me

class AddForeignKeysForYearAssignments < ActiveRecord::Migration[5.1]
  def up
    execute <<~SQL
      ALTER TABLE #{schema}.year_assignments
        ADD CONSTRAINT fk_year_assignments_lessons FOREIGN KEY (lesson_id)
          REFERENCES public.lessons(id)
    SQL
  end

  def down
    execute <<-SQL
      ALTER TABLE #{schema}.year_assignments
        DROP CONSTRAINT fk_year_assignments_lessons
    SQL
  end

  private

  def schema
    Apartment::Tenant.current
  end
end

@marcosvieiraftw
Copy link

Hey guys, let me help those who are stuck on this question.

The problem:
When you run migration task, you're running it on public schema - since tenants aren't created yet - and even if you force "public." (or any other) rails will strip this Source so on your schema.rb the add_foreign_key will be created without the reference to schema you want.

When you create a tenant, Apartment will do a db:schema:load (not running migrations as @arturtr presumed) to create tables/columns and the add_foreign_key without explicit reference to public will generate a fk pointing to table inside your private tenant. The result is: FK error on all your querys, because in fact postgres is trying to access a register that doesn't exist.

If you try to add reference on schema.rb manually, it will work and tenant will create column pointing to public. But as you know, it isn't sustainable.

The solution:
To be clear, lets work with 2 tables: User (public) and Notification (tenant private)

  • On notification migration, remove FK reference and add a integer field named user_id. (you can add an index)
  • On your Notification model, create relationship: belongs_to :user, class_name: 'User', :foreign_key => 'user_id'

Done, now you have everything working and pointing to the correct schemas.

Apartment '2.2.0'

Att,

@arturtr
Copy link

arturtr commented Dec 13, 2018

My solution works for us because we create new tenants manually with creating DB with rake db:migrate. It is not an ideal solution and (as you say) not works in other cases.

@mszyndel
Copy link

mszyndel commented Jan 3, 2019

@marcosvieiraftw either I don't understand your solution or it just removes db-level foreign key constraint and doesn't offer anything in return.

Passing foreign_key to belongs_to just tells Rails what column to look at for an id. Since you just follow convention here (other_table_name + _id) it has no added effect whatsoever. You will be able to remove a user and leave notifications hanging there...

@mszyndel
Copy link

mszyndel commented Jan 3, 2019

@mikecmpbll this is an issue Apartment should handle. As per DmytroVasin comment on rails/rails#28654, Postgres will omit public schema when returning column information because public is the default schema. Therefore schema.rb omits schema which people try to add as a prefix in their migrations.

Now, since Apartment runs migrations in other schemas, it should be detecting when add_foreign_key refers to a table for a shared model. How can this be Rails' responsibility when migrating schemas is a functionality added by Apartment?

I will look into how hard would it be to fix it but given that I have zero knowledge of the internals of ActiveRecord I guess I will fail at this.

@mszyndel
Copy link

mszyndel commented Jan 3, 2019

The best solution I came up with after more than 4 hours spent on this. Tested only on Rails 5.1.6.1.

If you really really want to use foreign keys from tenanted table to the shared one you can define a migration like this:

add_foreign_key :tenanted_table, "public.shared_table"

with any normal options, you would use. This migration will work correctly both on shared schema and tenant schemas as long as you only migrate the database. If you try to load schema from schema.rb or structure.sql it will fail by referencing shared_table inside the tenant schema.

You could avoid loading schema but in larger teams, it will become a nuisance with people trying to use command they are used to and getting weird errors. You could override db:schema:load task but... I wouldn't ;)
Changing schema.rb by hand doesn't work as it's overwritten everytime migrations are run.

The other solution would be to mimic what foreign key does in terms of referential integrity and add a before_destroy callback on your model. This callback should either RESTRICT deletion by using throw :abort, NULLIFY with update_all or destroy_all as a replacement for CASCADE. This is not an optimal solution but in my opinion, it's better than having issues with "weird" constraints when someone loads the schema.

@ancorcruz
Copy link
Contributor

I have run into the same issue with foreign keys on apartment excluded tables (public).

After some try/error I got to the same conclusion than @mszyndel. The way to go is to not create the FK and handle data integrity at the application level.

I'm using schema_plus_foreign_keys gem and it allows things like (following the previous example where users is the shared table and notifications the tenanted table):

add_reference :notifications, :user, index: true, foreign_key: { references: 'public.users' }

Existing tenants will migrate fine, however, it does not work for newly created tenants from the structure.sql file. Reasons are, the SQL will have something like REFERENCES public.users and Apartment gem will remove public. from the SQL to being able to create the new schema with the SQL dump. I haven't been able to figure out a solution for this as all foreign key definitions also include REFERENCES public. as the dumped schema is public. Then, there is no way to know when it should remove it and when not.

I have thought about a possible solution, use a different schema than public for shared data and leave public just for the SQL structure, in the end, it feels too much complexity for the benefit. I'm planning to move from apartment gem to column based tenancy within a single schema.

@rayancastro
Copy link

rayancastro commented Aug 29, 2019

Hello guys.

After struggling with this problem for a long time, i found a way to make rails works with cross-schema references, using the usual rails foreign key relations.

As long as you dont have the duplicate of the tables in the current schema, rails will fallback to the public schema

The problem: even if the you set the Model as an Excluded Model, Apartment still creates an empty table for it in every tenant schema, when it runs the migrations.

The solution: I've created a migration that drops all tables related to the excluded models, in every schema but the public one. This way we get rid of the useless empty tables, and rails automatically fallback to the public schema when it does not find the relation in the current schema.

class FixCrossSchemaForeignKeys < ActiveRecord::Migration[5.2]
  def up
    unless schema == "public"
      Apartment.excluded_models.each do |model|
        begin
          ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS #{schema}.#{model.downcase.pluralize} CASCADE")
        rescue ActiveRecord::StatementInvalid => e
          puts "Moving foward from the following error:"
          puts e.message
        end
      end
    end
  end

  def down
    raise ActiveRecord::IrreversibleMigration
  end

  private

  def schema
    Apartment::Tenant.current
  end
end

The drawback: It is an irreversible migration, and it only works for the excluded models you defined before this migration. If you need to make another cross-schema reference in the future, you would need to do another migration like this one.

@mikecmpbll
Apartment could try to implement this by default, preventing the tenant schemas to create tables from excluded models.

@ancorcruz
Copy link
Contributor

ancorcruz commented Sep 23, 2019 via email

@dalezak
Copy link

dalezak commented Jan 21, 2020

Unfortunately trying to add add_foreign_key with 'public.users' in my migration

add_foreign_key :meetings, 'public.users', column: :user_id, on_delete: :cascade

Only adds "users" to schema.rb also reported here.

add_foreign_key "meetings", "users", on_delete: :cascade

Which still causes the PG::ForeignKeyViolation error

PG::ForeignKeyViolation: ERROR:  
insert or update on table "meetings" violates foreign key constraint "fk_rails_400671c98f" 
DETAIL:  Key (user_id)=(1) is not present in table "users".

I like @marcosvieiraftw's suggestion above to enforce the foreign key in the Model class

belongs_to :user, class_name: 'User', foreign_key: 'user_id'

@xanderificnl
Copy link

My requirements demand data integrity starting at the database level. This was a tricky issue.

FWIW, I managed to get it working with foreign keys. Hope this helps.

# migrations
class CreateApplicationSchema < ActiveRecord::Migration[7.0]
  def change
    execute "CREATE SCHEMA IF NOT EXISTS application"
  end
end

class CreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table "application.users" do |t|
      t.timestamps
    end
  end
end

class CreatePosts < ActiveRecord::Migration[7.0]
  def change
    create_table :posts do |t|
      t.bigint :user_id
      t.timestamps
    end

    add_foreign_key :posts, "application.users", column: :user_id
  end
end

And configured Apartment:

# initializer
Apartment.configure do |config|
  config.excluded_models = []
end

As an added bonus, I don't have to configure exclude_models or database search paths.

# model
class User < ApplicationRecord
  self.table_name = "application.users"
end

class Post < ApplicationRecord
  belongs_to :user
end

Rails and Apartment however know nothing about the application schema, so in essence schema.rb will contain only the tenant schema but not application tables. I'm using pg_dump to export the application schema. Unfortunately, I wasn't able to specify which schemas I wanted without losing create extension statements, so I had to do the inverse and exclude the schemas I didn't want:

❯ pg_dump -d mbb_development -N '(public|tenant*)' -s > db/structure.sql

I still have to enhance some db:tasks to load db/structure.sql first on db:reset, db:schema:load, and to dump db/structure.sql and db/schema.rb but that's for another day.

Hopefully I haven't forgotten anything. If it doesn't work, let me know. Patching apartment would probably be more efficient.

In action with FactoryBot:

3.1.0 » Apartment::Tenant.create('test')
3.1.0 » Apartment::Tenant.switch!('test')

3.1.0 » Post.create(title: 'testing', user: create(:user))
3.1.0 » Post.all
=> [
  [0] #<Post:0x00007ff2b81dc380>
]
3.1.0 » Post.first.user
=> #<User:0x00007fd5a78da688>

3.1.0 » Apartment::Tenant.switch!('public')
3.1.0 » Post.all
=> []

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests