Skip to content

Commit ef266c0

Browse files
committed
v5.0.0: find_by_path and find_or_create_by_path` now takes either an array of strings or an array of attribute hashes
1 parent da3c1d7 commit ef266c0

15 files changed

+387
-288
lines changed

CHANGELOG.md

+29-14
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,48 @@
11
# Changelog
22

3+
### 5.0.0
4+
5+
#### Breaking API changes
6+
7+
* `find_by_path` and `find_or_create_by_path` now takes either an array of strings
8+
or an array of attribute hashes, which can include the inheritance column for STI support.
9+
* Removed the extraneous `base_class` `acts_as_tree` option—it needlessly duplicated ActiveRecord's method.
10+
* Removed the unused `name` `acts_as_tree` option.
11+
12+
#### Improvements and bugfixes
13+
14+
* Cleaned up the inheritance support methods to delegate correctly to ActiveRecord
15+
* Fixed a query generation error when ancestor paths exceeded 50 items.
16+
* Documented the `.touch` option
17+
318
### 4.6.3
419

5-
* More goodness from [Abdelkader Boudih](https://github.com/seuros), including rspec 3 support.
20+
* More goodness from [Abdelkader Boudih](https://github.com/seuros), including rspec 3 support.
621

722
### 4.6.2
823

9-
* Pulled in [106](https://github.com/mceachen/closure_tree/pull/106) which fixed a bug introduced
10-
in 4.6.0 which broke if the numeric ordering column wasn't named 'sort_order'. Tests have been
11-
added. Thanks for the fix, [Fission Xuiptz](https://github.com/fissionxuiptz)!
24+
* Pulled in [106](https://github.com/mceachen/closure_tree/pull/106) which fixed a bug introduced
25+
in 4.6.0 which broke if the numeric ordering column wasn't named 'sort_order'. Tests have been
26+
added. Thanks for the fix, [Fission Xuiptz](https://github.com/fissionxuiptz)!
1227

1328
### 4.6.1
1429

15-
* Address [issue 60](https://github.com/mceachen/closure_tree/issues/60) (use `.empty?` rather
16-
than `.nil?`—thanks for the suggestion, [Leonel Galán](https://github.com/leonelgalan),
17-
[Doug Mayer](https://github.com/doxavore) and [Samnang Chhun](https://github.com/samnang)!
30+
* Address [issue 60](https://github.com/mceachen/closure_tree/issues/60) (use `.empty?` rather
31+
than `.nil?`—thanks for the suggestion, [Leonel Galán](https://github.com/leonelgalan),
32+
[Doug Mayer](https://github.com/doxavore) and [Samnang Chhun](https://github.com/samnang)!
1833

1934
### 4.6.0
2035

21-
* Deterministically ordered trees are guaranteed to have a sort_order now.
36+
* Deterministically ordered trees are guaranteed to have a sort_order now.
37+
38+
**This may be a breaking change if you're expecting sort_order to be nullable.**
2239

23-
**This may be a breaking change if you're expecting sort_order to be nullable.**
40+
Many thanks to [David Schmidt](https://github.com/inetdavid) for raising and
41+
working on the issue!
2442

25-
Many thanks to [David Schmidt](https://github.com/inetdavid) for raising and
26-
working on the issue!
43+
* Added ```append_child``` and ```prepend_child```
2744

28-
* Added ```append_child``` and ```prepend_child```
29-
30-
* All raw SQL is now ```strip_heredoc```'ed
45+
* All raw SQL is now ```strip_heredoc```'ed
3146

3247
### 4.5.0
3348

Gemfile

+13-8
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
source 'https://rubygems.org'
2-
gem 'foreigner', :git => 'https://github.com/mceachen/foreigner.git'
1+
# This file was generated by Appraisal
2+
3+
source "https://rubygems.org"
4+
5+
gem "foreigner", :git => "https://github.com/mceachen/foreigner.git"
6+
gem "activerecord", "~> 3.2"
7+
gem "strong_parameters"
38

49
platforms :ruby, :rbx do
5-
gem 'mysql2'
6-
gem 'pg'
7-
gem 'sqlite3'
10+
gem "mysql2"
11+
gem "pg"
12+
gem "sqlite3"
813
end
914

1015
platforms :jruby do
11-
gem 'activerecord-jdbcmysql-adapter'
12-
gem 'activerecord-jdbcpostgresql-adapter'
13-
gem 'activerecord-jdbcsqlite3-adapter'
16+
gem "activerecord-jdbcmysql-adapter"
17+
gem "activerecord-jdbcpostgresql-adapter"
18+
gem "activerecord-jdbcsqlite3-adapter"
1419
end
1520

1621
gemspec

README.md

+30-20
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ and tracking user referrals.
1010
[![Code Climate](https://codeclimate.com/github/mceachen/closure_tree.png)](https://codeclimate.com/github/mceachen/closure_tree)
1111
[![Dependency Status](https://gemnasium.com/mceachen/closure_tree.png)](https://gemnasium.com/mceachen/closure_tree)
1212

13-
Substantially more efficient than
13+
Dramatically more performant than
1414
[ancestry](https://github.com/stefankroes/ancestry) and
1515
[acts_as_tree](https://github.com/amerine/acts_as_tree), and even more
1616
awesome than [awesome_nested_set](https://github.com/collectiveidea/awesome_nested_set/),
@@ -25,16 +25,16 @@ closure_tree has some great features:
2525
* __Best-in-class mutation performance__:
2626
* 2 SQL INSERTs on node creation
2727
* 3 SQL INSERT/UPDATEs on node reparenting
28+
* __Support for [concurrency](#concurrency)__ (using [with_advisory_lock](https://github.com/mceachen/with_advisory_lock))
2829
* __Support for Rails 3.2, 4.0, and 4.1__
29-
* __Support for Ruby 1.9 and 2.1 (jRuby and Rubinius are still in development)__
30+
* __Support for Ruby 1.9, 2.1, and jRuby 1.6.13
3031
* Support for reparenting children (and all their descendants)
31-
* Support for [concurrency](#concurrency) (using [with_advisory_lock](https://github.com/mceachen/with_advisory_lock))
32-
* Support for polymorphism [STI](#sti) within the hierarchy
33-
* ```find_or_create_by_path``` for [building out hierarchies quickly and conveniently](#find_or_create_by_path)
34-
* Support for [deterministic ordering](#deterministic-ordering) of children
32+
* Support for [single-table inheritance (STI)](#sti) within the hierarchy
33+
* ```find_or_create_by_path``` for [building out heterogeneous hierarchies quickly and conveniently](#find_or_create_by_path)
34+
* Support for [deterministic ordering](#deterministic-ordering)
3535
* Support for [preordered](http://en.wikipedia.org/wiki/Tree_traversal#Pre-order) traversal of descendants
3636
* Support for rendering trees in [DOT format](http://en.wikipedia.org/wiki/DOT_(graph_description_language)), using [Graphviz](http://www.graphviz.org/)
37-
* Excellent [test coverage](#testing) in a variety of environments
37+
* Excellent [test coverage](#testing) in a comprehensive variety of environments
3838

3939
See [Bill Karwin](http://karwin.blogspot.com/)'s excellent
4040
[Models for hierarchical data presentation](http://www.slideshare.net/billkarwin/models-for-hierarchical-data)
@@ -153,30 +153,40 @@ child1.ancestry_path
153153
154154
### find_or_create_by_path
155155
156-
We can do all the node creation and add_child calls with one method call:
156+
You can ```find``` as well as ```find_or_create``` by "ancestry paths".
157+
158+
If you provide an array of strings to these methods, they reference the `name` column in your
159+
model, which can be overridden with the `:name_column` option provided to ```acts_as_tree```.
157160
158161
```ruby
159162
child = Tag.find_or_create_by_path(["grandparent", "parent", "child"])
160163
```
161164
162-
You can ```find``` as well as ```find_or_create``` by "ancestry paths".
163-
Ancestry paths may be built using any column in your model. The default
164-
column is ```name```, which can be changed with the :name_column option
165-
provided to ```acts_as_tree```.
165+
As of v5.0.0, `find_or_create_by_path` can also take an array of attribute hashes:
166166
167-
Note that any other AR fields can be set with the second, optional ```attributes``` argument,
168-
and as of version 4.2.0, these attributes are added to the where clause as selection criteria.
167+
```ruby
168+
child = Tag.find_or_create_by_path([
169+
{name: "Grandparent", title: "Sr."},
170+
{name: "Parent", title: "Mrs."},
171+
{name: "Child", title: "Jr."}
172+
])
173+
```
174+
175+
If you're using STI, The attribute hashes can contain the `sti_name` and things work as expected:
169176

170177
```ruby
171-
child = Tag.find_or_create_by_path(%w{home chuck Photos"}, {:tag_type => "File"})
178+
child = Label.find_or_create_by_path([
179+
{type: 'DateLabel', name: '2014'},
180+
{type: 'DateLabel', name: 'August'},
181+
{type: 'DateLabel', name: '5'},
182+
{type: 'EventLabel', name: 'Visit the Getty Center'}
183+
])
172184
```
173-
This will pass the attribute hash of ```{:name => "home", :tag_type => "File"}``` to
174-
```Tag.find_or_create_by_name``` if the root directory doesn't exist (and
175-
```{:name => "chuck", :tag_type => "File"}``` if the second-level tag doesn't exist, and so on).
176185

177186
### Moving nodes around the tree
178187

179-
Nodes can be moved around to other parents, and closure_tree moves the node's descendancy to the new parent for you:
188+
Nodes can be moved around to other parents, and closure_tree moves the node's descendancy to the
189+
new parent for you:
180190
181191
```ruby
182192
d = Tag.find_or_create_by_path %w(a b c d)
@@ -252,6 +262,7 @@ When you include ```acts_as_tree``` in your model, you can provide a hash to ove
252262
* ```:destroy``` will destroy all descendant nodes (which runs the destroy hooks on each child node)
253263
* ```:name_column``` used by #```find_or_create_by_path```, #```find_by_path```, and ```ancestry_path``` instance methods. This is primarily useful if the model only has one required field (like a "tag").
254264
* ```:order``` used to set up [deterministic ordering](#deterministic-ordering)
265+
* ```:touch``` delegates to the `belongs_to` annotation for the parent, so `touch`ing cascades to all children (the performance of this for deep trees isn't currently optimal).
255266
256267
## Accessing Data
257268
@@ -529,7 +540,6 @@ end
529540

530541
```
531542

532-
533543
## Testing
534544

535545
Closure tree is [tested under every valid combination](http://travis-ci.org/#!/mceachen/closure_tree) of

closure_tree.gemspec

+7-9
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,25 @@ Gem::Specification.new do |gem|
88
gem.email = ['matthew-github@mceachen.org']
99
gem.homepage = 'http://mceachen.github.io/closure_tree/'
1010

11-
gem.summary = %q(Easily and efficiently make your ActiveRecord model support hierarchies)
11+
gem.summary = %q(Easily and efficiently make your ActiveRecord model support hierarchies)
1212
gem.description = gem.summary
13-
gem.license = 'MIT'
13+
gem.license = 'MIT'
1414

15-
gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
16-
gem.test_files = gem.files.grep(%r{^spec/})
17-
gem.required_ruby_version = '>= 1.9.3'
15+
gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
16+
gem.test_files = gem.files.grep(%r{^spec/})
17+
gem.required_ruby_version = '>= 1.9.3'
1818

1919
gem.add_runtime_dependency 'activerecord', '>= 3.2.0'
20-
gem.add_runtime_dependency 'with_advisory_lock', '>= 0.0.9' # <- to prevent duplicate roots
20+
gem.add_runtime_dependency 'with_advisory_lock', '>= 3.0.0'
2121

22-
gem.add_development_dependency 'rake'
2322
gem.add_development_dependency 'yard'
2423
gem.add_development_dependency 'rspec', '>= 3.0'
2524
gem.add_development_dependency 'rspec-instafail'
25+
# TODO: delete rspec-rails.
2626
gem.add_development_dependency 'rspec-rails' # FIXME: for rspec-rails and rspec fixture support
2727
gem.add_development_dependency 'uuidtools'
2828
gem.add_development_dependency 'database_cleaner'
2929
gem.add_development_dependency 'appraisal'
3030
gem.add_development_dependency 'timecop'
31-
3231
# gem.add_development_dependency 'ruby-prof' # <- don't need this normally.
33-
3432
end

lib/closure_tree/finders.rb

+36-48
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,34 @@ module ClosureTree
22
module Finders
33
extend ActiveSupport::Concern
44

5-
# Find a child node whose +ancestry_path+ minus self.ancestry_path is +path+.
5+
# Find a descendant node whose +ancestry_path+ will be ```self.ancestry_path + path```
66
def find_by_path(path, attributes = {})
77
return self if path.empty?
88
self.class.find_by_path(path, attributes, id)
99
end
1010

11-
# Find a child node whose +ancestry_path+ minus self.ancestry_path is +path+
12-
def find_or_create_by_path(path, attributes = {}, find_before_lock = true)
13-
attributes[:type] ||= self.type if _ct.subclass? && _ct.has_type?
14-
# only bother trying to find_by_path on the first call:
15-
(find_before_lock && find_by_path(path, attributes)) || begin
16-
subpath = path.is_a?(Enumerable) ? path.dup : [path]
17-
return self if subpath.empty?
18-
child_name = subpath.shift
19-
attrs = attributes.merge(_ct.name_sym => child_name)
20-
_ct.with_advisory_lock do
21-
# shenanigans because children.create is bound to the superclass
22-
# (in the case of polymorphism):
23-
child = self.children.where(attrs).first || begin
24-
self.class.new(attrs).tap do |ea|
25-
# We know that there isn't a cycle, because we just created it, and
26-
# cycle detection is expensive when the node is deep.
27-
ea._ct_skip_cycle_detection!
28-
self.children << ea
29-
end
11+
# Find or create a descendant node whose +ancestry_path+ will be ```self.ancestry_path + path```
12+
def find_or_create_by_path(path, attributes = {})
13+
subpath = _ct.build_ancestry_attr_path(path, attributes)
14+
return self if subpath.empty?
15+
16+
found = find_by_path(subpath, attributes)
17+
return found if found
18+
19+
attrs = subpath.shift
20+
_ct.with_advisory_lock do
21+
# shenanigans because children.create is bound to the superclass
22+
# (in the case of polymorphism):
23+
child = self.children.where(attrs).first || begin
24+
# Support STI creation by using base_class:
25+
_ct.create(self.class, attrs).tap do |ea|
26+
# We know that there isn't a cycle, because we just created it, and
27+
# cycle detection is expensive when the node is deep.
28+
ea._ct_skip_cycle_detection!
29+
self.children << ea
3030
end
31-
child.find_or_create_by_path(subpath, attributes, false)
3231
end
32+
child.find_or_create_by_path(subpath, attributes)
3333
end
3434
end
3535

@@ -54,7 +54,7 @@ module ClassMethods
5454

5555
# Fix deprecation warning:
5656
def _ct_all
57-
(ActiveRecord::VERSION::MAJOR >= 4 ) ? all : scoped
57+
(ActiveRecord::VERSION::MAJOR >= 4) ? all : scoped
5858
end
5959

6060
def without(instance)
@@ -115,49 +115,37 @@ def find_all_by_generation(generation_level)
115115
_ct.scope_with_order(s)
116116
end
117117

118-
def ct_scoped_attributes(scope, attributes, target_table = table_name)
119-
attributes.inject(scope) do |scope, pair|
120-
scope.where("#{target_table}.#{pair.first}" => pair.last)
121-
end
122-
end
123-
124118
# Find the node whose +ancestry_path+ is +path+
125119
def find_by_path(path, attributes = {}, parent_id = nil)
126-
path = path.is_a?(Enumerable) ? path.dup : [path]
127-
scope = where(_ct.name_sym => path.pop).readonly(false)
128-
scope = ct_scoped_attributes(scope, attributes)
120+
path = _ct.build_ancestry_attr_path(path, attributes)
121+
if path.size > _ct.max_join_tables
122+
return _ct.find_by_large_path(path, attributes, parent_id)
123+
end
124+
scope = where(path.pop)
129125
last_joined_table = _ct.table_name
130-
# MySQL doesn't support more than 61 joined tables (!!):
131-
path.first(50).reverse.each_with_index do |ea, idx|
126+
path.reverse.each_with_index do |ea, idx|
132127
next_joined_table = "p#{idx}"
133128
scope = scope.joins(<<-SQL.strip_heredoc)
134129
INNER JOIN #{_ct.quoted_table_name} AS #{next_joined_table}
135130
ON #{next_joined_table}.#{_ct.quoted_id_column_name} =
136-
#{connection.quote_table_name(last_joined_table)}.#{_ct.quoted_parent_column_name}
131+
#{connection.quote_table_name(last_joined_table)}.#{_ct.quoted_parent_column_name}
137132
SQL
138-
scope = scope.where("#{next_joined_table}.#{_ct.name_column}" => ea)
139-
scope = ct_scoped_attributes(scope, attributes, next_joined_table)
133+
scope = _ct.scoped_attributes(scope, ea, next_joined_table)
140134
last_joined_table = next_joined_table
141135
end
142-
result = scope.where("#{last_joined_table}.#{_ct.parent_column_name}" => parent_id).first
143-
if path.size > 50 && result
144-
find_by_path(path[50..-1], attributes, result.primary_key)
145-
else
146-
result
147-
end
136+
scope.where("#{last_joined_table}.#{_ct.parent_column_name}" => parent_id).readonly(false).first
148137
end
149138

150139
# Find or create nodes such that the +ancestry_path+ is +path+
151140
def find_or_create_by_path(path, attributes = {})
152-
find_by_path(path, attributes) || begin
153-
subpath = path.dup
154-
root_name = subpath.shift
141+
attr_path = _ct.build_ancestry_attr_path(path, attributes)
142+
find_by_path(attr_path) || begin
143+
root_attrs = attr_path.shift
155144
_ct.with_advisory_lock do
156145
# shenanigans because find_or_create can't infer that we want the same class as this:
157146
# Note that roots will already be constrained to this subclass (in the case of polymorphism):
158-
attrs = attributes.merge(_ct.name_sym => root_name)
159-
root = roots.where(attrs).first || roots.create!(attrs)
160-
root.find_or_create_by_path(subpath, attributes)
147+
root = roots.where(root_attrs).first || _ct.create!(self, root_attrs)
148+
root.find_or_create_by_path(attr_path)
161149
end
162150
end
163151
end

0 commit comments

Comments
 (0)