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

When duping an object with translations, also dup the translations #84

Closed
wants to merge 2 commits into from

Conversation

pwim
Copy link
Collaborator

@pwim pwim commented Sep 8, 2017

I noticed mobility doesn't support duping translations like globalize does.

I took a stab at implementing it, but not sure if this is the right approach.

Also, it wasn't clear for me where to put the test. I'm assuming you want to test something like this against every backend. Where should I add real tests?

@shioyama
Copy link
Owner

shioyama commented Sep 8, 2017

Thanks for the PR! You're right this is not a case that is currently handled, and the way you've done it using write/read makes sense.

Regarding specs, there is a (backend-independent) duplication spec, I think it could go there first. In principle it shouldn't need to be tested against every backend since it's just built up from reads and writes, but need to think a bit more about that. To test against all backends, it would need to go in the shared examples (see /spec/support/shared_examples/).

@shioyama
Copy link
Owner

shioyama commented Sep 8, 2017

I'll have a more careful look later today.

@pwim
Copy link
Collaborator Author

pwim commented Sep 8, 2017

Thinking about it some more, this handling is not necessarily needed. For backends where the translation is stored in the model itself (e.g., translatable columns), I don't think anything needs to be done, as the model's dup should take care of it. I don't think what I'm doing will do any harm, but I think the implications are worth thinking about.

@shioyama
Copy link
Owner

shioyama commented Sep 8, 2017

Yes I agree, which is why I need to think about it. I feel like it shouldn't be the default, maybe somehow as an option, but not sure how that would fit naturally.

@shioyama
Copy link
Owner

shioyama commented Sep 8, 2017

I thought about this, and in the end if something like this is going to be added, it would have to be a backend-specific thing (done in the setup block for each backend). So e.g.:

setup do |attributes, options|
  # ...

  dup_method = Module.new do
    define_method :initialize_dup do |other|
      # ... do something special applied to attributes
      super
    end
  end
  include dup_method
end

This way only backends where it's relevant to duplicate values would do it, and since it's backend-specific it could bypass read/write to say duplicate associations (in the case of table/key_value backends), etc.

I'm still not sure it makes sense, but that's the way I would probably envision doing it, if it seems important to have.

@pwim
Copy link
Collaborator Author

pwim commented Sep 9, 2017

I personally don't see the need for initialize_dup behaviour to be configurable by the user of the gem, as it makes things more complicated for little gain. Either the gem should handle it, or decide it is up to the consumer of the gem.

The main reason I think this behaviour should be included in mobility is that one of the core goals of the gem is to provide a unified API regardless of the storage backend. However, without mobility handling dup, the API is not consistent across the backends.

The argument against this is that dup normally creates a shallow copy of an object. I think this is part of the reason why ActiveRecord::Core#dup doesn't copy associations, only attributes. Though with AR#dup, it is also more likely that you wouldn't necessarily want to copy associations. For instance, say we have an Event model that has_many :tickets. When duping, we most likely wouldn't want to dup the tickets association, only the properties of the event itself.

However, the documentation of dup notes

This method may have class-specific behavior. If so, that behavior will be documented under the #initialize_copy method of the class.

So it isn't as though there's a strict rule against us handle duping in mobility.

Given all of this, I'd favour adding the behaviour to the backends that don't handle dup properly right now. I agree that by having it be backend specific, we can just duplicate the associated translations, which should minimize the chance of introducing bugs and also avoid any performance issues.

@pwim
Copy link
Collaborator Author

pwim commented Sep 14, 2017

After experimenting with this some more, it looks like the workaround proposed in the PR behaves a bit differently than anticipated in some circumstances.

In my own application, I'm using the table backend, and working around it by adding the following to any models that use translation:

  def initialize_dup(source)
    super
    self.translations = source.translations.map(&:dup)
  end

So this further cements for me that we should fix this on a per backend basis. Can I update this PR to fix it for the table backend like the above, or are you still wanting to consider how to handle this?

@shioyama
Copy link
Owner

@pwim Please go ahead and fix for the table backend, that would be great.

KeyValue backends require more work to get it working properly, so skip
implementing for now.
@pwim
Copy link
Collaborator Author

pwim commented Sep 15, 2017

I've rebased this PR, and implemented dup support for the ActiveRecord Table backend. All the other backends, except the KeyValue backends, seem to work automatically. Surprisingly, even the Sequel Table backend does, so perhaps they have some built in support.

Using the same technique for the KeyValue backend didn't work. I guess because it is setting up multiple associations to the same table. So for now, I've left it unimplemented.

@shioyama
Copy link
Owner

Thanks! Will have a look on the weekend.

self.send("#{association_name}=", source.send(association_name).map(&:dup))
end
end
include callback_methods
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in general the right way to do this, the only thing I'd point out is that if you define your attributes separately, e.g.:

class Post < ApplicationRecord
  extend Mobility
  translates :title
  translates :content
  translates :author
end

Then this method will actually super through the same code three times. This might seem a bit pedantic, but in Mobility I try to ensure that defining attributes separately or together essentially does the same thing, so try to avoid this type of thing (even where the result, like here, is the same, since assigning the same thing any number times does not change anything).

This code path can be avoided by defining a constant for the module which includes the (camel-cased) name of the association as well as the backend, and then including that:

module_name = "MobilityTable#{association_name.camelcase}"
unless const_defined?(module_name) do
  callback_methods = Module.new do
    # ...
  end
  include const_set(module_name, callback_methods)
end

This way, even if you call the code multiple times, you will only include the module once (unless the association name is different, in which case you should include it separately anyway.

There may be a simpler/better way to do this...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, your approach is a good way of doing it. Strictly speaking, I don't think you need the const_defined? check, as including the same module multiple times won't do anything, but I guess it is safer to keep the check.

The other approach would be to have setup include a single module, that generates an initialize_dup method that handles associations. Something like

setup do |_attributes, options|
  self.mobility_association_names << association_name
  include InitializeDup
end

module InitializeDup
  def initialize_dup(source)
    super
    self.class.mobility_association_names.each do |association_name|
      self.send("#{association_name}=", source.send(association_name).map(&:dup))
    end
  end
end

I gather though you've designed mobility with avoiding adding such state to the classes it uses it.

Copy link
Owner

@shioyama shioyama Sep 16, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strictly speaking, I don't think you need the const_defined? check, as including the same module multiple times won't do anything, but I guess it is safer to keep the check.

Yes that's true, I just thought if it's already defined there's no point re-defining it - but agree better without since it saves a couple lines which only apply to an edge case anyway.

I gather though you've designed mobility with avoiding adding such state to the classes it uses it.

Yes exactly, the goal is to add as little to the model class as possible, and a name like mobility_association_names would potentially conflict with other backends (which could be used in the same model).

It's overkill for 99% of current use-cases, but I want to leave the door open to use any combination of backends, applied any number of times, in the same model without fear that they would conflict with either each other in any way.

The tradeoff is that you have to define a constant on the model. I'm happy with any constant name as long as it includes the backend name and the associaiton name.

@pwim
Copy link
Collaborator Author

pwim commented Sep 16, 2017

Ok, I went with your suggestion of defining a constant for the module.

I was wrong to suggest that we could drop the check of whether it is defined as it will produce a already initialized constant warning if the method is called multiple times with the same association name.

@shioyama shioyama closed this in e803847 Sep 16, 2017
@shioyama
Copy link
Owner

Thanks for the great PR! Just merged it.

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

Successfully merging this pull request may close these issues.

2 participants