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

Multitenancy #17

Closed
airblade opened this issue Oct 1, 2015 · 6 comments
Closed

Multitenancy #17

airblade opened this issue Oct 1, 2015 · 6 comments

Comments

@airblade
Copy link

airblade commented Oct 1, 2015

Hi Pat,

I have patched Gutentag to support multitenancy, i.e. a self-contained set of tags for each account in my Rails app, and I thought you might like to see how. It's been running happily in production for a few weeks.

First I added a scoping column to Gutentag::Tag which I called :tenant_id. This involved modifying the migration:

create_table :gutentag_tags do |t|
  t.integer :tenant_id, :null => false  # added this line

  t.string :name, :null => false
  t.timestamps :null => false
end

add_index :gutentag_tags, [:name, :tenant_id], :unique => true  # changed this line

Then I patched the class to take :tenant_id into account:

# config/initializers/gutentag.rb
Gutentag::Tag.class_eval do
  attr_accessible :tenant_id if ActiveRecord::VERSION::MAJOR == 3

  scope :by_tenant_id, ->(tenant_id) { where tenant_id: tenant_id }

  # Change uniqueness validation to act within a scope.
  #
  # We cannot modify the existing one in place so we:
  #
  #1) remove it (works on Rails 3.2; not tested on Rails 4)
  _validators[:name].reject! { |v| v.is_a?(ActiveRecord::Validations::UniquenessValidator) }

  _validate_callbacks.reject! do |callback|
    callback.raw_filter.is_a?(ActiveRecord::Validations::UniquenessValidator) &&
      callback.raw_filter.attributes == [:name]
  end

  #2) add a new one
  validates :name, uniqueness: {case_sensitive: false, scope: :tenant_id}

  def self.find_by_name_and_tenant_id(name, tenant_id)
    where(tenant_id: tenant_id).find_by_name(name)
  end

  def self.find_or_create_by_name_and_tenant_id(name, tenant_id)
    find_by_name_and_tenant_id(name, tenant_id) || create(name: name, tenant_id: tenant_id)
  end
end

The only tricky part was updating the uniqueness validator to scope by :tenant_id.

Then I needed to tell Gutentag::Persistence to use a custom tagger which is aware of a tenant's scope. Unfortunately this was also a little tricky because it's only ever instantiated inside the after_save callback set up by the has_many_tags method – which is difficult to reach inside.

I created a module which a taggable model can include instead of calling has_many_tags:

# The taggable must respond to `#tenant_id`.
#
# Alternatively we could change the signature of `#has_many_tags` to take a block, which would
# be a lambda version of a taggable's current `#tenant_id` method.  Then the `after_save` callback
# could get the `tenant_id` by calling the block we passed in.
module Taggable
  def self.included(base)
    base.extend ClassMethods
    base.has_many_tags  # bootstrap
  end

  module ClassMethods
    # I need to inject my own tagger into Gutentag::Persistence so I can use the tenant_id when
    # finding or creating tags.  Although Gutentag::Persistence supports an injectable tagger,
    # the way it is instantiated in the after_save callback doesn't give any opportunity to inject
    # a tagger.
    #
    # I can't find a way to skip the after_save callback on Gutentag::ActiveRecord because it is
    # defined inline as a lambda.  Were it a symbol/instance method, it ought to be skippable so:
    #
    #   skip_callback :save, :after, :persist
    #
    # Instead I copy-paste the non-callback parts of Gutentag::ActiveRecord::ClassMethods#has_many_tags
    # and add my own after_save callback.
    def has_many_tags
      has_many :taggings, :class_name => 'Gutentag::Tagging', :as => :taggable,
        :dependent => :destroy
      has_many :tags,     :class_name => 'Gutentag::Tag',
        :through => :taggings

      after_save do |instance|
        persister = Gutentag::Persistence.new(instance)
        persister.tagger = TenantTagger.new(instance.tenant_id)
        persister.persist
      end
    end
  end

  def tenant_id
    raise NotImplementedError, 'taggable must implement'
  end
end

Finally, here is the custom tagger:

class TenantTagger
  def initialize(tenant_id)
    @tenant_id = tenant_id
  end

  def find_by_name(name)
    Gutentag::Tag.find_by_name_and_tenant_id(name, tenant_id)
  end

  def find_or_create(name)
    Gutentag::Tag.find_or_create_by_name_and_tenant_id(name, tenant_id)
  end

  private

  attr_reader :tenant_id
end

Client code can use it like this:

class Foo < ActiveRecord::Base
  belongs_to :account  # for example

  include Taggable

  def tenant_id
    account.id
  end
end

Overall I was pleased by how little I had to change conceptually. The implementation was a little messy because it isn't straightforward to modify validators or after-save callbacks.

I know you don't necessarily want to support multitenancy, but I wonder whether we could rearrange Gutentag's code at all, wihtout changing its function, to make extending for multitenancy easier?

Thanks for such a cleanly and clearly written gem!

Cheers,
Andy

Cross-ref: #9.

pat added a commit that referenced this issue Nov 21, 2015
This allows others to modify the behaviour, whereas if it's a proc, such changes aren't easily done. See #17.
pat added a commit that referenced this issue Nov 21, 2015
Related to #17, this is so if others modify the behaviour of the tag model, they can change the validations that are in play. It's worth noting that adding a custom tag_validations class/object/proc (whatever it is, it must respond to call and take the tag model as an argument) will only work in Rails, due to the lazy loading of models. In non-Rails apps, the model will be loaded with the rest of the gem, and changing tag_validations will have no impact. I'm open to suggestions on how to better manage this.
@pat
Copy link
Owner

pat commented Nov 21, 2015

Hey Andy

Been mulling over this on and off, and finally got a chance to do something about it. I've not taken your code wholesale into the gem - am still loath to have official support for multi-tenancy in Gutentag itself - but there are two changes which should help your modifications:

Via 0cbc601 the after_save callback in a taggable model has changed from a Proc to a private method (persist_tags), so you can customise that behaviour far more easily.

Via 51bd8f2 you can customise the validations being added to the Gutentag::Tag model by setting Gutentag.tag_validations to something that responds to call (a Proc, a class, an instance) and takes a single argument, which is the Gutentag::Tag class. This will remove the need for you to remove the default validations.

Gutentag.tag_validations = lambda { |klass|
  klass.validates :name, presence: true,
    uniqueness: {case_sensitive: false, scope: :tenant_id}
}

That second change is only Rails-friendly, due to Rails' lazy loading of models. It will not work with Sinatra or other frameworks (though I'm certainly welcome to feedback on how it could be better structured to do so).

These should help clean up your code a bit… are there other aspects that you feel could be managed better to allow your modifications to happen more cleanly?

@airblade
Copy link
Author

airblade commented Dec 2, 2015

Hey Pat,

Those two changes greatly clean up my code – thanks! I was able to delete 36 lines of ugly customisation :)

The only other modification I wonder about is how best to add scopes to Gutentag::Tag. Currently I class eval them like this:

Gutentag::Tag.class_eval do
  scope :by_tenant_id, ->(tenant_id) { where tenant_id: tenant_id }
end

Would you do it this way or another way?

@pat
Copy link
Owner

pat commented Dec 3, 2015

scope is a public method (at least, it is in ActiveRecord 4.1), so it's possible to write that scope like so:

Gutentag::Tag.scope :by_tenant_id, ->(tenant_id) { Gutentag::Tag.where tenant_id: tenant_id }

The catch with that is that I'm not sure it would work appropriately if chained on top of another scope. Not the end of the world, but given it then becomes less obvious/normal, I'd probably go with the class_eval call instead. At this point, I can't think of any way to abstract this behaviour out neatly so it's easier to inject.

@airblade
Copy link
Author

airblade commented Dec 3, 2015

That's all good to know, thanks.

I can't see any further way to simplify my multitenancy changes. For the sake of completeness, here they are:

Gutentag.tag_validations = lambda { |klass|
  klass.validates :name,
    presence: true,
    uniqueness: {case_sensitive: false, scope: :tenant_id}
}

Gutentag::Tag.class_eval do
  attr_accessible :tenant_id if ActiveRecord::VERSION::MAJOR == 3

  scope :by_tenant_id, ->(tenant_id) { where tenant_id: tenant_id }

  def self.find_by_name_and_tenant_id(name, tenant_id)
    where(tenant_id: tenant_id).find_by_name(name)
  end

  def self.find_or_create_by_name_and_tenant_id(name, tenant_id)
    find_by_name_and_tenant_id(name, tenant_id) || create(name: name, tenant_id: tenant_id)
  end
end

module Taggable
  def self.included(base)
    base.has_many_tags
  end

  def tenant_id
    raise NotImplementedError, 'taggable must implement'
  end

  private

  # Override Gutentag::ActiveRecord#persist_tags so I can use a `tenant_id`-aware tagger.
  def persist_tags
    persister = Gutentag::Persistence.new(self)
    persister.tagger = TenantTagger.new(tenant_id)
    persister.persist
  end

  class TenantTagger
    def initialize(tenant_id)
      @tenant_id = tenant_id
    end

    def find_by_name(name)
      Gutentag::Tag.find_by_name_and_tenant_id(name, tenant_id)
    end

    def find_or_create(name)
      Gutentag::Tag.find_or_create_by_name_and_tenant_id(name, tenant_id)
    end

    private

    attr_reader :tenant_id
  end
end

Thanks for all the help!

@airblade airblade closed this as completed Dec 3, 2015
@airblade
Copy link
Author

airblade commented Dec 9, 2015

I extracted this into a gem: gutentag-multitenancy.

Thanks again for the help!

@pat
Copy link
Owner

pat commented Dec 9, 2015

Great stuff! :)

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

2 participants