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

insert_at respects unique not null check (>= 0) db constraints #246

Merged
merged 33 commits into from
Jan 23, 2017

Conversation

zharikovpro
Copy link
Contributor

Following this discussion.

Proposal to resolve db constraints issues. It targets insert_at cause it's often used by UI clients. Great demo of this is ActiveAdmin Reorderable gem.

@zharikovpro
Copy link
Contributor Author

P.S. Battle-tested on a real project with unique not null constraint. Works like a charm.

@brendon
Copy link
Owner

brendon commented Dec 23, 2016

Thanks @zharikovpro, that's a good solution. Are you able to add some tests to cover off what this change caters for? Just tests that would have failed without this change.

I assume it's also thread safe since the intermediate change is backed out before the transaction ends?

@zharikovpro
Copy link
Contributor Author

It's thread safe because of with_lock. Will write minimal tests.

@zharikovpro
Copy link
Contributor Author

zharikovpro commented Dec 23, 2016

Hmm, you pointed out that we need to go deeper with this issue. With unique constraint query like "UPDATE pos = pos +1" will fail. Here's more details.

Really two options there. One is to make increment in two passes, like

      define_singleton_method :update_all_with_touch_unique_workaround do |updates|
        if connection.index_exists?(caller_class.table_name, column, unique: true)
          # unique constraint prevents regular increment_all, so we use work-around with negative values
          # http://stackoverflow.com/questions/7703196/sqlite-increment-unique-integer-field
          # it's not specific to SQLite only, PostgreSQL has same issue
          update_all_with_touch "#{quoted_position_column} = -#{quoted_position_column_with_table_name}"
          update_all_with_touch updates.sub('= (', '= (-')
        else
          update_all_with_touch updates
        end
      end

I dislike this, cause it still wont allow CHECK > 0 constraint if someone needs it. Also I suppose gem itself does not handle negative positions well. Still, this solution is locally tested by me, holded.

Another approach will be to do one-by-one updates inside increment_all, moving rows from the very bottom to the gap. This will allow CHECK > 0 constraint and surely will prevent someone of entering negative position into db, which is great in my opinion. Performance is the price for correctness in that case, and additional time will be required for me to add tests for the positive CHECK.

@brendon do you think it's the right way to do it? I still want to make it right asap, if you support release.

@zharikovpro
Copy link
Contributor Author

Ok, finally managed to add db constraints that will fail test suite DefaultScopedNotNullUniquePositiveConstraintsTest on my local machine. Also implemented sequential update for both SQLite and PostgreSQL. Sadly, forgot to test with appraisals. Hope it will be sorted out anytime soon, too.

@zharikovpro
Copy link
Contributor Author

zharikovpro commented Dec 23, 2016

Green light! One-by-one increments/decrements will only be enabled upon unique index detection. Following the principle of least astonishment and backwards compatibility, old users will get the same exact behaviour as before with new build. And with new gem version when someone adds unique index to the column (can also add check >= 0) it will just work.

Copy link
Owner

@brendon brendon left a comment

Choose a reason for hiding this comment

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

Thanks @zharikovpro for your hard work on this :) I've left a few comments but agree with your approach overall. Let me know if anything doesn't make sense :)

# temporary move after bottom with gap, avoiding duplicate values
# gap is required to leave room for position increments
# positive number will be valid with unique not null check (>= 0) db constraint
tmp_position_after_everything = acts_as_list_class.unscoped.maximum(position_column).to_i + 2
Copy link
Owner

Choose a reason for hiding this comment

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

Does this unscoping remove the acts_as_list scope? I assume you're not wanting to the position column to be globally unique? just unique per scope?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I assume you're not wanting to the position column to be globally unique?

I want exactly this to bypass unique index on table column constraint. Still, you're right that it's not needed. There could be no such situation when different scopes have same positions in one table with unique index.

# unique constraint prevents regular increment_all and forces to do increments one by one
# http://stackoverflow.com/questions/7703196/sqlite-increment-unique-integer-field
# both SQLite and PostgreSQL (and most probably MySQL too) has same issue
update_one_by_one = acts_as_list_list.connection.index_exists?(acts_as_list_list.table_name, position_column, unique: true)
Copy link
Owner

Choose a reason for hiding this comment

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

I'm thinking this should be defined more globally (even though it's only used here) as it feels more like a configuration option. What do you think? You could set it at initialisation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

At first I thought that if you have unique index this is not an option, really, but a requirement. Still someone may want to disable this for performance reasons iif DB has deferred unique check on this table, and this will work too. Preparing update to make :sequential_updates option.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, this option could be used in other methods in future, definitely the way to go, thanks for the feedback.

)

if update_one_by_one
items.order("#{quoted_position_column_with_table_name} ASC").pluck(:id).each do |id|
Copy link
Owner

Choose a reason for hiding this comment

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

Why are you plucking the id's only to re-find the records in order to decrement etc...? You could call .reload instead?

@@ -317,32 +317,53 @@ def increment_positions_on_all_items
# Reorders intermediate items to support moving an item from old_position to new_position.
def shuffle_positions_on_intermediate_items(old_position, new_position, avoid_id = nil)
return if old_position == new_position
scope = acts_as_list_list
scope = acts_as_list_list.select(:id)
Copy link
Owner

Choose a reason for hiding this comment

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

If you can remove the pluck then you can remove this too I think.

Copy link
Contributor Author

@zharikovpro zharikovpro Dec 29, 2016

Choose a reason for hiding this comment

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

Yes, pluck will be removed, it's redundant. Still, wouldnt it be performance-wise to only select ids for repositioning? Otherwise DB response will be a little bit larger and slower.

@zharikovpro
Copy link
Contributor Author

zharikovpro commented Dec 29, 2016

@brendon all your comments made sense and changes were made, thank you. Please check update with new sequential_updates option. It has smart default behaviour, covered by tests.

Hope for the review even if not this year ;) and wish you a lot of fun at winter holidays 🎉

@zharikovpro
Copy link
Contributor Author

@brendon, your review suggestions were implemented. Some notes:

  1. sequential_updates? was made private and contains lazily evaluated option in cases when it's not specified via acts_as_list options.

Initially, I thought about making this a class-level function and evaluate only once for every instance. But you showed a case with multiple tenants, where earlier established connection can be altered, so I intentionally made this evaluation instance-level. As I can see, ActiveRecord caches tables and indexes info, so this should result in no performance penalty (i.e. no db hit during this check). If one want to avoid this type of checks at all, he can explicitly set sequential_updates option to true or false upon mixin initialization.

  1. It's debatable topic whether or not tests should ever test private methods behavior, like I test sequential_updates? Again, I think it's better than not to test it, cause that "smart detection" in case of missing option may have implementation flaws without it. And testing this indirectly via public-only methods is a lot more complex and completely untransparent.

  2. sequential_updates? is defined dynamically, cause it relies on sequential_updates_option from configuration.

Waiting for another great supportive review from you :)

Copy link
Owner

@brendon brendon left a comment

Choose a reason for hiding this comment

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

Thanks @zharikovpro, we're nearly there. I think there are just a few tweaks to make to tidy things up :)

caller_class.class_eval do
@sequential_updates = nil

define_method :sequential_updates? do
Copy link
Owner

Choose a reason for hiding this comment

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

class_eval allows us to just write normal Ruby to be executed within the scope of the class. So you can just do

caller_class.class_eval do
  
  private
  
  def sequential_updates?
    ...
  end
end

define_method is primarily used to define a method with a string interpolated variable name as far as I've seen it. Give it a go and let me know if it doesn't work as expected.

Copy link
Contributor Author

@zharikovpro zharikovpro Jan 22, 2017

Choose a reason for hiding this comment

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

So you can just do

Nope, calling sequential_updates? that way will produce 'undefined local variable or method `sequential_updates_option'. Cause, well, sequential_updates_option would be really not a local variable nor method (undefined). This definer trick makes it available for the sequential_updates? block, cause block do have access to the caller scope variables.

Another possible option is to store the whole acts_as_list configuration as a class variable after initialization. But this is not in the style of this gem.

Please check a perfect example of doing the same thing inside another definer. It defines method which implementation relies on config option. I do it the same way.

Copy link
Owner

Choose a reason for hiding this comment

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

Ah I see :) Sorry about that :) I was focused on the instance variable and didn't realise you were talking about the incoming config :) I agree, no class variables is definitely preferred.

module ActiveRecord::Acts::List::SequentialUpdatesDefiner #:nodoc:
def self.call(caller_class, column, sequential_updates_option)
caller_class.class_eval do
@sequential_updates = nil
Copy link
Owner

Choose a reason for hiding this comment

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

It seems unintuitive to set a variable at this point. Perhaps set it within def sequential_updates? like so:

@sequential_updates = nil unless defined?(@sequential_updates)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, removed class-level initializer.

@brendon
Copy link
Owner

brendon commented Jan 21, 2017

@zharikovpro, definitely test the private methods unit test style. :)

@brendon brendon mentioned this pull request Jan 21, 2017
@zharikovpro
Copy link
Contributor Author

Hmm, somehow Travis build is failing with 'Bundler could not find compatible versions for gem "thread_safe"' - @brendon , can you check it, please? It clearly looks as an issue unrelated to my code changes and I wonder how it can be fixed.

@brendon
Copy link
Owner

brendon commented Jan 22, 2017

Thanks @zharikovpro, I think travis is having a jruby problem at the moment. I've initiated a rebuild so we'll see if that works :)

Otherwise I'm happy with the changes as they sit now and will approve them once the tests pass :)

@@ -0,0 +1,21 @@
module ActiveRecord::Acts::List::SequentialUpdatesDefiner #:nodoc:
Copy link
Owner

Choose a reason for hiding this comment

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

Just realised that this class name doesn't match the file name. Can you change the class name as all the other definer classes are ...MethodDefiner :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, that eagle eye) Fixed.

@zharikovpro
Copy link
Contributor Author

zharikovpro commented Jan 22, 2017

@brandon, I've noticed some changes in build logs, they may be related with recently broken jruby builds.

Your latest green master build 4 days ago uses bundler-1.13.7. My green builds use it, too. Exactly 3 days ago bundler 1.14.0 was released, and next day - bundler 1.14.1. Latest failing builds are using it, and bundle install fails.

Smells like an issue with bundler version. Could you try lock bundler version on Travis and see if it helps? I've tested this locally, and it worked on osx, same jruby version, same bundler version. Still, Travis uses linux. And it really looks like bundler version update issue, or a really rare coincidence.

@zharikovpro
Copy link
Contributor Author

By the way, bundler 1.14 announcement is not in official blog, yet.

@brendon
Copy link
Owner

brendon commented Jan 23, 2017

Good idea @zharikovpro :) I'm happy for you to fix the bundler version in the travis.yml file in this PR (with a comment about the reason in the file) to get this suite green :)

@zharikovpro
Copy link
Contributor Author

I do not know how to fix this using travis.yml 😊 , and only can google for a solution. One points to a build customization..

@zharikovpro
Copy link
Contributor Author

Fixed! 🎉

@brendon brendon merged commit cb8dfb5 into brendon:master Jan 23, 2017
@brendon
Copy link
Owner

brendon commented Jan 23, 2017

Well done! I'm going to release 0.9.0 that will include this. Thank you so much for your work on this and I look forward to working with you again in the future :)

@zharikovpro
Copy link
Contributor Author

Cheers! Can't wait to update gem version in production 😎

@zharikovpro zharikovpro deleted the unique_not_null branch January 23, 2017 00:57
@fabn
Copy link
Contributor

fabn commented Jan 23, 2017

@zharikovpro great job, congratulations.

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.

3 participants