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

Versioned associations feature #24

Merged
merged 16 commits into from Mar 28, 2017

Conversation

Projects
None yet
3 participants
@charlie-wasp
Copy link
Collaborator

commented Feb 16, 2017

It's attempt to implement #19

At the time it lacks some test cases, it covers only belongs_to and has_many associations.

And don't pay attention to a numerous commits, I'll squash it into one when code will be cleaned and fixed

@charlie-wasp charlie-wasp force-pushed the charlie-wasp:associations branch 2 times, most recently from b44b7db to 6de47e0 Feb 16, 2017

expect(old_post.comments.first.content).to eql('My comment')

very_old_post = post.at(time(100))
expect(very_old_post.comments.length).to eql(0)

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 16, 2017

Author Collaborator

Note the length method usage instead of size. It corresponds to the problem I described in the issue #19.
Unlike size, length method uses load_target, so we can get what we want with it

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Feb 16, 2017

Oops, travis is not satisfied with my code, I'll get to it tomorrow

@@ -50,6 +50,9 @@ def at(ts)

object_at = dup
object_at.apply_diff(version, log_data.changes_to(version: version))
object_at.id = id

This comment has been minimized.

Copy link
@palkan

palkan Feb 17, 2017

Owner

Looks like that was a bug. Can you, please, add specs for this and, maybe, it would be better to extract this fix into a separate PR to release it ASAP?

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 17, 2017

Author Collaborator

If we consider, that results of at invocation couldn't access their association, as a bug, then of course I can extract this particular change

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 17, 2017

Author Collaborator

@palkan I have opened #25

@palkan
Copy link
Owner

left a comment

Overall looks good 👍

The only one crucial feature is missing. Consider the example (from http://cultofmartians.com/tasks/logidze-associations.html):

# 2017-01-19
post = Post.create!(post_params)

# 2017-01-22
comment = post.comments.create(body: 'My comment')

# 2017-01-24
comment.update!(body: 'New text')

# смотрим пост до обновления комментария (первый случай)
old_post = post.at('2017-01-23')

# сам пост в этот момент не менялся, но у нас есть ассоциация, которая поменялась
old_post.comments.first #=> 'My comment'

Thus we cannot rely on log_data.current_version.time, we need something like requested_past_time or whatever.

@@ -122,6 +135,14 @@ def apply_diff(version, diff)
self
end

def in_the_past?

This comment has been minimized.

Copy link
@palkan

palkan Feb 17, 2017

Owner

Let's prefix this method to avoid possible collisions, smth like logidze_past?.

target.map! do |object|
object unless has_logidze? object

object.at(time)

This comment has been minimized.

Copy link
@palkan

palkan Feb 17, 2017

Owner

We can use at! here too to avoid #dup calls.

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 23, 2017

Author Collaborator

It's strange, but when I use at, test case with empty comments failed. Somehow super return [false] instead of [], so has_logidze? fails. I guess, somehow at! messes up something inside. I can investigate it, if it's important

def load_target
target = super

return target if inversed

This comment has been minimized.

Copy link
@palkan

palkan Feb 17, 2017

Owner

Can you explain this line?

Also, specs are needed.

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 17, 2017

Author Collaborator

I'll try to explain it with the example:

when we call post.comments, each comment in this association will have its post association initialized as inversed. So I thought, that we don't want to perform our operations in this case. I'll check this more closely during specs writing

This comment has been minimized.

Copy link
@palkan

palkan Feb 17, 2017

Owner

Yep. But this post object would exactly the object we call association on, so it will be at the correct time. But, nevertheless, we need specs.

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 24, 2017

Author Collaborator

I made a spec for inversed association. Correct me, if I wrong: in case of belongs_to inversed has_many is not populated anyway, so we don't care about it, right? E.g. in pseudo-code

user = article.user
user.articles.first == article # > false

This comment has been minimized.

Copy link
@palkan
target
end

def has_logidze?(object)

This comment has been minimized.

Copy link
@palkan

palkan Feb 17, 2017

Owner

Let's add has_logidze? class method in has_logidze.rb to simplify the check.

@charlie-wasp charlie-wasp force-pushed the charlie-wasp:associations branch 5 times, most recently from 40bbfa4 to 674bf53 Feb 20, 2017

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Feb 20, 2017

@palkan want to notice, that I introduced a new model called Article in order to fix specs. It's basically the same as the Post, but with the log_data column. I realized, that Post doesn't have it at the start by design, because this model is used to test generators. So for not messing with that, I created a new model. Is it okay?

@palkan

This comment has been minimized.

Copy link
Owner

commented Feb 20, 2017

@charlie-wasp Yep, that's good.

# Return a dirty copy of record at specified time
# If time is less then the first version, then return nil.
# If time is greater then the last version, then return self.
def at(ts)
ts = parse_time(ts)
@logidze_requested_ts = ts

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 23, 2017

Author Collaborator

@palkan here's how I covered the case, when at doesn't change the object. Can we do better? I am not particularly happy with attr_reader

This comment has been minimized.

Copy link
@palkan

palkan Feb 27, 2017

Owner

I'd prefer to store this value in log_data object.

association.singleton_class.prepend Logidze::VersionedAssociation
end

association.reload if @logidze_requested_ts

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 23, 2017

Author Collaborator

It seems, that with this we can use .size!

This comment has been minimized.

Copy link
@palkan

palkan Feb 25, 2017

Owner

Hm, but we don't want to reload the association on every call. So we have to add another variable to track associations reload (btw, we should track each association separately) to avoid double-reload:

post = Post.find(id)

# first load, without versions
post.comments

post.at!(2.days.ago)

# here we should reload (it would be better to reset, not reload, btw)
post.comments.first

# no reset/reload here
post.comments.second

# with fresh object
post = Post.find(id).at(2.days.ago)

# no reset/reload, 'cause association hasn't been loaded before
post.comments

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 27, 2017

Author Collaborator

Yes, it was pretty brutal :) I used reload to invoke load_target in case of already loaded before at invocation belongs_to association:

article.user
old_article = article.at(...)
old_article.user # here load_target will not be called, so we won't have versioned user

But I can work around this issue another way, without reload or reset at all. I pushed commit recently with these changes.

But it seems, that there is no clean way to achieve size method working, so I got back to length so far

@@ -13,6 +13,10 @@ module ClassMethods # :nodoc:
def has_logidze
include Logidze::Model
end

def has_logidze?
included_modules.include? Logidze::Model

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 23, 2017

Author Collaborator

@palkan Is it what your suggestion was about?

This comment has been minimized.

Copy link
@palkan

palkan Feb 25, 2017

Owner

No. I suggested to add:

def has_logidze?
  true
end

to Logidze::Model::ClassMethods.

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 27, 2017

Author Collaborator

Mmm, I thought about that, but classes without has_logidze wouldn't respond to this method. So we would have to check it beforehand, which not very convenient. Am I missing something?

This comment has been minimized.

Copy link
@palkan

palkan Feb 27, 2017

Owner

Yep, you're right. Than we can check for has_logidze? method itself too (i.e. respond_to?(:has_logidze?). It's still a little bit faster than checking included modules.

module Logidze
module VersionedAssociation
module SingularAssociation
def reader(force_reload = false)

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 27, 2017

Author Collaborator

It turned out, in case of singular association we can use reader method to version instead of load_target. It will save us from the problem of already loaded associations, and multiple invocation won't make us suffer, because logidze won't do anything on the similar at calls

This comment has been minimized.

Copy link
@palkan

palkan Feb 27, 2017

Owner

But we can still use load_target for single association too, can't we? I would prefer to stick with as less monkey-patched method as possible. Thus, if we can handle all cases within load_target, then let's do that.

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 28, 2017

Author Collaborator

Yes, absolutely, but with the load_target only we have to deal with the reset/reload problem

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Mar 2, 2017

Author Collaborator

@palkan I've got an idea. We override only load_path and association, as it was before my last commit, but in association we add something like this:

if association.is_a?(ActiveRecord::Associations::SingularAssociation) &&
   association.loaded? &&
   !association.inversed
  # we can extract functionality from load_target and use it here
  association.target.at! @logidze_requested_ts
end

What do you think about it? The same level of monkey-patching and no reload/reset confusion :)

This comment has been minimized.

Copy link
@palkan

palkan Mar 2, 2017

Owner

And how to handle association loading? I.e. post.at(...).user?
How to prevent double-at!?

I have another idea: we can use stale_target? to handle reload. Just:

def stale_target?
  logidze_state? || super
end

def logidze_stale?
  # we can store logidze_requested_ts in the association too
  loaded? && !inversed? && (owner.logidze_requested_ts != logidze_requested_ts)
end

That should work for all kind of associations.

But I found one more problem with collection associations: we have to think about empty?, any?, blank? methods (which use SQL if target has not been loaded) and also update_all, delete_all.
For presence-like checks we can force load target; and I think we should raise an error when a user tries to use update_all / delete_all on the past version.
Potential caveat:

post = Post.find(...)
post.comments.size #=> 2

# rollback and persist
post.undo!

# no raise
post.comments.update_all(...)

Too complicated. We need more specs, first of all)

UPD: one more issue: post.comment_ids.

Maybe it would be easier to write our own CollectionAssociation that mimics the AR one and make it readonly, than to monkey-patch everything.

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Mar 3, 2017

Author Collaborator

That's a lot of information to process :)

  1. And how to handle association loading? I.e. post.at(...).user?
    How to prevent double-at!?

    All double at! wouldn't affect association, because there will be the same timestamp, so at! will just return self without any processing. Or I miss some scenario?

  2. we can use stale_target?

    What advantage does it have over explicit reload or reset call?

  3. I found one more problem with collection associations

    Got it, I will start with specs for all these methods

Issue gone wild, didn't it? :)

This comment has been minimized.

Copy link
@palkan

palkan Mar 3, 2017

Owner

Issue gone wild, didn't it? :)

Oh, yeah)

stale_target? is a standard AR mechanism to check, whether we need reload or not, and it's used all over the associations codebase. Thus we're unlikely to miss something. And we should not care about #reload ourselves. I hope so, at least.

All double at! wouldn't affect association ...

Ok.

end

module CollectionAssociation
def load_target

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Feb 27, 2017

Author Collaborator

In case of collection association we use load_target, because it will be used by CollectionProxy object

@charlie-wasp charlie-wasp force-pushed the charlie-wasp:associations branch from 6927c89 to 7b1c122 Feb 27, 2017

def logidze_past?
return false unless log_data

time = log_data.current_version.time

This comment has been minimized.

Copy link
@palkan

palkan Feb 27, 2017

Owner

We should use logidze_requested_ts here

@charlie-wasp charlie-wasp force-pushed the charlie-wasp:associations branch from 7b1c122 to 0a86729 Mar 6, 2017

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 6, 2017

Added usage of stale_target? method

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 8, 2017

@palkan I added specs and implementation of presence-like methods. You said

For presence-like checks we can force load target

Did you mean, that we can basically do something like this?

def empty?
  reload
  super
end
@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 13, 2017

@palkan what do you think about my last commit? And what have we got on our plate? I feel kind of lost :) What we should do about update_all? Also, we should take care in a similar way about item_ids= method, I suppose.


if target.is_a? Array
target.map! do |object|
object unless object.class.has_logidze?

This comment has been minimized.

Copy link
@palkan

palkan Mar 13, 2017

Owner

Why do we check for logidze here? We've already checked that in #association method.

This comment has been minimized.

Copy link
@charlie-wasp

charlie-wasp Mar 14, 2017

Author Collaborator

Not actually, we don't check, whether target class has logidze or not in the #association method :) And now I think, that it would be better to check it there really

Should we also add specs for the case of associations without logidze enabled?

@palkan

This comment has been minimized.

Copy link
Owner

commented Mar 13, 2017

@charlie-wasp Implementing update_all for versioned associations would be a huge pain)
I think, we can start with read-only features (that should be the most popular case).

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 14, 2017

@palkan so we leave update_all and the similar alone or we should implement raising exception on their invocation?

@charlie-wasp charlie-wasp force-pushed the charlie-wasp:associations branch from 04117fa to 6d93268 Mar 14, 2017

@palkan

This comment has been minimized.

Copy link
Owner

commented Mar 14, 2017

so we leave update_all and the similar alone or we should implement raising exception on their invocation?

Just ignore them. And we should specify in the Readme the list of supported methods and a note telling that other methods may not work with versions.

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 14, 2017

@palkan good. Now can you tell, please, what else should I do, so we can merge this particular PR? As far as I see, only place for storing logidze_requested_ts is not fixed yet

@palkan
Copy link
Owner

left a comment

A few more comments

def association(name)
association = super

unless logidze_past? && association.klass.respond_to?(:has_logidze?)

This comment has been minimized.

Copy link
@palkan

palkan Mar 14, 2017

Owner

We should also check whether VersionedAssociation has been already prepended

def logidze_past?
return false unless @logidze_requested_ts

time = @logidze_requested_ts

This comment has been minimized.

Copy link
@palkan

palkan Mar 14, 2017

Owner

We don't need this local var at all


return false if target.empty?

owner.logidze_requested_ts != target.first.logidze_requested_ts

This comment has been minimized.

Copy link
@palkan

palkan Mar 14, 2017

Owner

There can be a case when we revert one of the target records into a previous state:

post = post.at(122)
post.comments.size #=> 2
post.comments.first.at!(100)
post.at!(100)

# this version is for 122 time
post.comments.second

So, it's better to use target.any? { ... } here.


module CollectionAssociation
def ids_reader
reload

This comment has been minimized.

Copy link
@palkan

palkan Mar 14, 2017

Owner

Shouldn't we check whether association has been loaded? If it was, then we don't need to reload, do we? The same goes for empty?.

# super
# end

# def is_errored

This comment has been minimized.

Copy link
@palkan

palkan Mar 14, 2017

Owner

Cleanup, please


should_appply_logidze = logidze_past? &&
association.klass.respond_to?(:has_logidze?) &&
!association.singleton_class.include?(Logidze::VersionedAssociation)

This comment has been minimized.

Copy link
@houndci-bot

houndci-bot Mar 14, 2017

Style/MultilineOperationIndentation: Align the operands of an expression in an assignment spanning multiple lines.

association = super

should_appply_logidze = logidze_past? &&
association.klass.respond_to?(:has_logidze?) &&

This comment has been minimized.

Copy link
@houndci-bot

houndci-bot Mar 14, 2017

Style/MultilineOperationIndentation: Align the operands of an expression in an assignment spanning multiple lines.

@charlie-wasp charlie-wasp force-pushed the charlie-wasp:associations branch from f9f117b to 95f2605 Mar 14, 2017

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 16, 2017

@palkan are there any more changes to be done?

@palkan

This comment has been minimized.

Copy link
Owner

commented Mar 17, 2017

@charlie-wasp

Final stuff:

  • Great Readme entry with all the caveats explained (or maybe event Wiki article)

  • Change log entry

  • It would be better to rename the PR, it's confusing

As it turned out this feature is rather complex and looks error-prone. What do you think about making it optional and disabled by default? And to enable a user must run smth like Logidze.enable_associations_versioning!

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 17, 2017

@palkan

What do you think about making it optional and disabled by default?

Sounds very reasonable, feature seems quite experimental at the time. I'll try to add a switch. How about putting it into initializer file?

Great Readme entry with all the caveats explained (or maybe event Wiki article)

I would prefer Wiki, especially, if this feature will be turned off by default. How can I create wiki page? At the moment Wiki seems to be disabled

@palkan

This comment has been minimized.

Copy link
Owner

commented Mar 17, 2017

@charlie-wasp

At the moment Wiki seems to be disabled

Check again, please. I've added you to collaborators.

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 17, 2017

@palkan all set, can edit wiki, thank you!

expect(old_comment.article.title).to eql('Article')
end
end

This comment has been minimized.

Copy link
@houndci-bot

houndci-bot Mar 21, 2017

Style/EmptyLinesAroundBlockBody: Extra empty line detected at block body end.

@charlie-wasp charlie-wasp force-pushed the charlie-wasp:associations branch from 5603060 to 6ab2f85 Mar 21, 2017

@charlie-wasp charlie-wasp changed the title Saving association changes Versioned associations feature Mar 21, 2017

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 24, 2017

@palkan I wrote a wiki page, and the README and CHANGELOG entries. Also, I made this feature optional and disabled by default

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 28, 2017

@palkan hello, any updates on this?

@palkan

palkan approved these changes Mar 28, 2017

@palkan palkan merged commit 3be54be into palkan:master Mar 28, 2017

3 checks passed

ci/circleci Your tests passed on CircleCI!
Details
continuous-integration/travis-ci/pr The Travis CI build passed
Details
hound No violations found. Woof!
@palkan

This comment has been minimized.

Copy link
Owner

commented Mar 28, 2017

Great work! Thanks!

@charlie-wasp charlie-wasp deleted the charlie-wasp:associations branch Mar 28, 2017

@charlie-wasp

This comment has been minimized.

Copy link
Collaborator Author

commented Mar 28, 2017

@palkan thank you for the opportunity to contribute and your patience, it was a long run 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.