similar_models 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c7c43faef16009cad0a971ec3afc6efc716bd77723d45395a70365e2a96fa9b
4
- data.tar.gz: ac4deb962291c450f53cbf4731ada109ad3a505e009225dcae1f87495c817802
3
+ metadata.gz: 1512f7d1b0d895cc90aa6bf3357e8379aac8f57848de01f2a0d05816107e8936
4
+ data.tar.gz: 5001875c74fce6ba9bfbce5af20424742c87564d07a7b16393f953ada0b59cfa
5
5
  SHA512:
6
- metadata.gz: 228a426d2b7a8f10e0db47034e66a8f166ca957ef05e242fc787eeb2c84add141c4b4ba4b95a38f4eaa909cb13d69b8209db968d06b8f4d90ab46445828791c4
7
- data.tar.gz: 3dfee9418bb74aaddc686f96be94a69073b395f7a9edeac5a7a6900044c70cb0386a2da7ac7b84aa69f8766bcb8eb8b12c6c303bbb4f55615846f610c7fa0b1c
6
+ metadata.gz: 76f8b14e266fc0cf9063c19d5a39c6ce346b05539a450ab100e13e0440b7ea6293b4c02c1250f1e9a68a37fb9a7d952b3b6380bf3ea64bdd5c01984848d001a2
7
+ data.tar.gz: 51e379dc7d45bd905d0a52d791b8cd9a869ee5fe285848ced7ff3a579e4758ccb677095faadd5b55d812055acc87b4c0dbdc260e95aaa1ef8b51fa983d50b32b
data/README.md CHANGED
@@ -1,8 +1,16 @@
1
1
  # Similar Models
2
2
 
3
- Adds a `similar_{model name plural}` method to an active record model, but can be set to any name using `as: {method name}`. It returns the most similar models of the same class based on associated models in common.
3
+ Adds a `similar_#{model_name.plural}` instance and class method to an active record model, but can be set to any name using `as: {method name}`.
4
4
 
5
- The association(s) have to be many to many, so either [habtm](http://guides.rubyonrails.org/association_basics.html#the-has-and-belongs-to-many-association) or [has_many :through](http://guides.rubyonrails.org/association_basics.html#the-has-many-through-association).
5
+ The instance method returns models that have associated models in common ordered by most in common first.
6
+
7
+ A practical example for this could be linking to related blog posts when rendering an individual blog post. Related blog posts could be related by author, tag or author and tag combined.
8
+
9
+ The class method returns models ordered by most associated models in common.
10
+
11
+ If the commonality count is the same then a second order clause of `created_at` takes precedence if present.
12
+
13
+ The association(s) have to be many to many, so either [habtm](https://guides.rubyonrails.org/association_basics.html#has-and-belongs-to-many) or [has_many :through](https://guides.rubyonrails.org/association_basics.html#has-many-through).
6
14
 
7
15
  ## Installation
8
16
 
@@ -23,64 +31,80 @@ $ bundle
23
31
  Post example
24
32
 
25
33
  ```ruby
26
- class Post < ActiveRecord::Base
27
- has_many :author_posts
28
- has_many :authors, through: :author_posts
29
- has_and_belongs_to_many :tags
30
-
31
- has_similar_models :authors
32
- has_similar_models :tags, as: :similar_posts_by_tag
33
- has_similar_models :authors, :tags, as: :similar_posts_by_author_and_tag
34
+ class Post < ApplicationRecord
35
+ has_many :author_posts
36
+ has_many :authors, through: :author_posts
37
+ has_and_belongs_to_many :tags
38
+
39
+ has_similar_models :authors
40
+ has_similar_models :tags, as: :similar_posts_by_tag
41
+ has_similar_models :authors, :tags, as: :similar_posts_by_author_and_tag
34
42
  end
35
43
 
36
- class Tag < ActiveRecord::Base
44
+ class Tag < ApplicationRecord
37
45
  end
38
46
 
39
- class Author < ActiveRecord::Base
40
- has_many :author_posts
47
+ class Author < ApplicationRecord
48
+ has_many :author_posts
41
49
  end
42
50
 
43
- class AuthorPosts < ActiveRecord::Base
44
- belongs_to :author
45
- belongs_to :post
51
+ class AuthorPosts < ApplicationRecord
52
+ belongs_to :author
53
+ belongs_to :post
46
54
  end
47
55
  ```
48
56
 
49
- To return the posts with the most authors in common with `post` in descending order:
57
+ To return posts with authors in common with the `post` model by most in common first:
50
58
 
51
59
  ```ruby
52
60
  post.similar_posts
53
61
  ```
54
62
 
63
+ To return posts ordered by most authors in common:
64
+ ```ruby
65
+ Post.similar_posts
66
+ ```
67
+
55
68
  The returned object is an ActiveRecord::Relation and so chaining of other query methods is possible:
56
69
 
57
70
  ```ruby
58
- post.similar_posts.where(posts.created_at: 10.days.ago..).limit(5)
71
+ post.similar_posts.where(created_at: 10.days.ago..).limit(5)
59
72
  ```
60
73
 
61
- To return the posts with the most tags in common with `post` in descending order:
74
+ To return posts with tags in common with the `post` model by most in common first:
62
75
 
63
76
  ```ruby
64
77
  post.similar_posts_by_tag
65
78
  ```
66
79
 
67
- To return the posts with the most authors and tags in common with `post` in descending order:
80
+ To return posts ordered by most tags in common:
81
+ ```ruby
82
+ Post.similar_posts_by_tag
83
+ ```
84
+
85
+ To return posts with the authors and tags in common with the `post` model by most in common first:
68
86
 
69
87
  ```ruby
70
88
  post.similar_posts_by_author_and_tag
71
89
  ```
72
90
 
91
+ To return posts ordered by most authors and tags in common:
92
+
93
+ ```ruby
94
+ Post.similar_posts_by_author_and_tag
95
+ ```
96
+
73
97
  The count of the associated models in common is accessible on each returned model:
74
98
 
75
99
  ```ruby
76
- post.similar_posts_model_count
77
- post.similar_posts_by_tag_model_count
78
- post.similar_posts_by_author_and_tag_model_count
100
+ post.similar_posts_commonality_count
101
+ post.similar_posts_by_tag_commonality_count
102
+ post.similar_posts_by_author_and_tag_commonality_count
79
103
  ```
80
104
 
81
- **Note multiple associations do not work with sqlite.**
105
+ **Note multiple associations for the instance method do not work with sqlite.**
82
106
 
83
- Because of the use of `group`, pagination is not supported.
107
+ **Pagination is not supported on the instance method due to the use of `group by`.**
84
108
 
85
109
  ## In conjunction with acts-as-taggable-on
86
110
 
@@ -1,3 +1,3 @@
1
1
  module SimilarModels
2
- VERSION = '0.3.0'
2
+ VERSION = '0.4.1'
3
3
  end
@@ -3,9 +3,66 @@ require 'similar_models/version'
3
3
  module SimilarModels
4
4
 
5
5
  def has_similar_models(*many_to_many_associations, as: nil)
6
- as = "similar_#{model_name.plural}" unless as
6
+ as ||= "similar_#{model_name.plural}"
7
7
 
8
- # defaults to 'def similar_{model name}'
8
+ # example sql query for one many to many association:
9
+ #
10
+ # SELECT posts.*,
11
+ # (select count(*) from author_posts where post_id != posts.alt_id and author_id in
12
+ # (select author_id from author_posts where post_id = posts.alt_id)) AS similar_posts_commonality_count
13
+ # FROM "posts"
14
+ # ORDER BY similar_posts_commonality_count DESC, created_at DESC
15
+ #
16
+ define_singleton_method as do
17
+ primary_key_ref = "#{table_name}.#{primary_key}"
18
+ similarity_counts = []
19
+
20
+ many_to_many_associations.each do |many_to_many_association|
21
+ association = reflect_on_association(many_to_many_association)
22
+ join_table, foreign_key, association_foreign_key = join_table_values(association)
23
+
24
+ similarity_counts <<
25
+ "(select count(*) from #{join_table} where #{foreign_key} != #{primary_key_ref} and " \
26
+ "#{association_foreign_key} in " \
27
+ "(select #{association_foreign_key} from #{join_table} where #{foreign_key} = #{primary_key_ref}))"
28
+ end
29
+
30
+ order_clause = "#{as}_commonality_count DESC"
31
+ order_clause += ", created_at DESC" if column_names.include?('created_at')
32
+ select("#{table_name}.*, #{similarity_counts.join(' + ')} AS #{as}_commonality_count").order(order_clause)
33
+ end
34
+
35
+ # example sql query for one many to many association:
36
+ #
37
+ # SELECT posts.*, count(posts.alt_id) AS similar_posts_commonality_count
38
+ # FROM "posts"
39
+ # INNER JOIN author_posts ON author_posts.post_id = posts.alt_id
40
+ # WHERE "posts"."alt_id" != ? AND
41
+ # author_posts.author_id IN (select author_posts.author_id from author_posts where author_posts.post_id = ?)
42
+ # GROUP BY posts.alt_id
43
+ # ORDER BY similar_posts_commonality_count DESC, created_at DESC
44
+ #
45
+ # example sql query for two many to many associations:
46
+ #
47
+ # SELECT posts.*, count(posts.alt_id) AS similar_posts_by_author_and_tag_commonality_count
48
+ # FROM (
49
+ # (SELECT "posts".* FROM "posts"
50
+ # INNER JOIN author_posts ON author_posts.post_id = posts.alt_id
51
+ # WHERE (author_posts.author_id IN
52
+ # (select author_posts.author_id from author_posts where author_posts.post_id = ?)
53
+ # )
54
+ # )
55
+ # UNION ALL
56
+ # (SELECT "posts".* FROM "posts"
57
+ # INNER JOIN posts_tags ON posts_tags.post_id = posts.alt_id
58
+ # WHERE (posts_tags.tag_id IN
59
+ # (select posts_tags.tag_id from posts_tags where posts_tags.post_id = ?)
60
+ # )
61
+ # )
62
+ # )
63
+ # AS posts WHERE "posts"."alt_id" != ? GROUP BY posts.alt_id, posts.created_at, posts.updated_at
64
+ # ORDER BY similar_posts_by_author_and_tag_commonality_count DESC, created_at DESC
65
+ #
9
66
  define_method as do
10
67
  table_name = self.class.table_name
11
68
  primary_key = self.class.primary_key
@@ -13,8 +70,8 @@ module SimilarModels
13
70
  association_scopes = []
14
71
 
15
72
  many_to_many_associations.each do |many_to_many_association|
16
- assocation = self.class.reflect_on_association(many_to_many_association)
17
- join_table, foreign_key, association_foreign_key = self.class.join_table_values(assocation)
73
+ association = self.class.reflect_on_association(many_to_many_association)
74
+ join_table, foreign_key, association_foreign_key = self.class.join_table_values(association)
18
75
 
19
76
  association_scopes << self.class.where(
20
77
  "#{join_table}.#{association_foreign_key} IN \
@@ -23,14 +80,16 @@ module SimilarModels
23
80
  ).joins("INNER JOIN #{join_table} ON #{join_table}.#{foreign_key} = #{primary_key_ref}")
24
81
  end
25
82
 
26
- scope = self.class.select("#{table_name}.*, count(#{primary_key_ref}) AS #{as}_model_count").
27
- where.not(primary_key => self.id).order("#{as}_model_count DESC")
28
- group_by_clause = self.class.column_names.map { |column| "#{table_name}.#{column}"}.join(', ')
83
+ order_clause = "#{as}_commonality_count DESC"
84
+ order_clause += ", created_at DESC" if self.class.column_names.include?('created_at')
85
+ scope = self.class.select("#{table_name}.*, count(#{primary_key_ref}) AS #{as}_commonality_count").
86
+ where.not(primary_key => self.id).order(order_clause)
29
87
 
30
88
  # if there is only one many-to-many association no need to use UNION sql syntax
31
89
  if association_scopes.one?
32
- scope.merge(association_scopes.first).group(group_by_clause)
90
+ scope.merge(association_scopes.first).group(primary_key_ref)
33
91
  else
92
+ group_by_clause = self.class.column_names.map { |column| "#{table_name}.#{column}"}.join(', ')
34
93
  scope.from("((#{association_scopes.map(&:to_sql).join(') UNION ALL (')})) AS #{table_name}").group(group_by_clause)
35
94
  end
36
95
  end
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
8
8
  spec.version = SimilarModels::VERSION
9
9
  spec.authors = ["Jolyon Pawlyn"]
10
10
  spec.email = ["jpawlyn@gmail.com"]
11
- spec.description = %q{Adds an instance method to an active record model that returns the most similar models based on associated models in common}
12
- spec.summary = %q{Returns models that have the most associated models in common}
11
+ spec.description = %q{Adds a `similar_#{model_name.plural}` instance and class method to an active record model and returns models based on associated models in common ordered by most in common first}
12
+ spec.summary = %q{Returns models that have associated models in common ordered by most in common first}
13
13
  spec.homepage = "https://github.com/jpawlyn/similar_models"
14
14
  spec.license = "MIT"
15
15
 
data/spec/post_spec.rb CHANGED
@@ -5,34 +5,52 @@ describe Post do
5
5
  let(:author2) { Author.create! }
6
6
  let(:author3) { Author.create! }
7
7
  let(:author4) { Author.create! }
8
+ let(:author5) { Author.create! }
8
9
  let(:tag1) { Tag.create! }
9
10
  let(:tag2) { Tag.create! }
10
11
  let(:tag3) { Tag.create! }
11
12
  let(:tag4) { Tag.create! }
12
13
 
13
14
  context 'has_many through:' do
14
- it 'return posts that have the most authors in common with post' do
15
- post = Post.create! authors: [author1, author2, author3]
16
- post1 = Post.create! authors: [author1, author4]
17
- post2 = Post.create! authors: [author1, author2]
18
- post3 = Post.create! authors: [author4]
19
- post4 = Post.create! authors: [author1, author2, author3]
20
-
21
- expect(post.similar_posts.map(&:similar_posts_model_count)).to eq([3, 2, 1])
22
- expect(post.similar_posts).to eq([post4, post2, post1])
15
+ let!(:post) { Post.create! authors: [author1, author2, author3] }
16
+ let!(:post1) { Post.create! authors: [author1] }
17
+ let!(:post2) { Post.create! authors: [author4] }
18
+ let!(:post3) { Post.create! authors: [author1, author2, author3, author4] }
19
+ let!(:post4) { Post.create! authors: [author5] }
20
+
21
+ describe '.similar_posts' do
22
+ it 'return posts ordered by most authors in common' do
23
+ expect(described_class.similar_posts.map(&:similar_posts_commonality_count)).to eq([5, 4, 2, 1, 0])
24
+ expect(described_class.similar_posts).to eq([post3, post, post1, post2, post4])
25
+ end
26
+ end
27
+
28
+ describe '#similar_posts' do
29
+ it 'return posts that have authors in common with `post` ordered by most in common first' do
30
+ expect(post.similar_posts.map(&:similar_posts_commonality_count)).to eq([3, 1])
31
+ expect(post.similar_posts).to eq([post3, post1])
32
+ end
23
33
  end
24
34
  end
25
35
 
26
36
  context 'has_and_belongs_to_many' do
27
- it 'return posts that have the most tags in common with post' do
28
- post = Post.create! tags: [tag1, tag2, tag3]
29
- post1 = Post.create! tags: [tag1, tag4]
30
- post2 = Post.create! tags: [tag1, tag2]
31
- post3 = Post.create! tags: [tag4]
32
- post4 = Post.create! tags: [tag1, tag2, tag3]
33
-
34
- expect(post.similar_posts_by_tag.map(&:similar_posts_by_tag_model_count)).to eq([3, 2, 1])
35
- expect(post.similar_posts_by_tag).to eq([post4, post2, post1])
37
+ let!(:post) { Post.create! tags: [tag1, tag2, tag3] }
38
+ let!(:post1) { Post.create! tags: [tag1, tag4] }
39
+ let!(:post2) { Post.create! tags: [tag2] }
40
+ let!(:post3) { Post.create! tags: [tag2, tag3] }
41
+
42
+ describe '.similar_posts_by_tag' do
43
+ it 'return posts ordered by most tags in common' do
44
+ expect(described_class.similar_posts_by_tag.map(&:similar_posts_by_tag_commonality_count)).to eq([4, 3, 2, 1])
45
+ expect(described_class.similar_posts_by_tag).to eq([post, post3, post2, post1])
46
+ end
47
+ end
48
+
49
+ describe '#similar_posts_by_tag' do
50
+ it 'return posts that have tags in common with `post` ordered by most in common first' do
51
+ expect(post.similar_posts_by_tag.map(&:similar_posts_by_tag_commonality_count)).to eq([2, 1, 1])
52
+ expect(post.similar_posts_by_tag).to eq([post3, post2, post1])
53
+ end
36
54
  end
37
55
  end
38
56
 
@@ -43,9 +61,19 @@ describe Post do
43
61
  let!(:post3) { Post.create! tags: [tag4] }
44
62
  let!(:post4) { Post.create! authors: [author1], tags: [tag1, tag2, tag3] }
45
63
 
46
- it 'return posts that have the most authors and tags in common with post' do
47
- expect(post.similar_posts_by_author_and_tag.map(&:similar_posts_by_author_and_tag_model_count)).to eq([5, 4, 1])
48
- expect(post.similar_posts_by_author_and_tag).to eq([post1, post4, post2])
64
+ describe '.similar_posts_by_author_and_tag' do
65
+ it 'return posts ordered by most authors and tags in common' do
66
+ expect(described_class.similar_posts_by_author_and_tag.map(&:similar_posts_by_author_and_tag_commonality_count))
67
+ .to eq([10, 10, 8, 3, 1])
68
+ expect(described_class.similar_posts_by_author_and_tag).to eq([post1, post, post4, post2, post3])
69
+ end
70
+ end
71
+
72
+ describe '#similar_posts_by_author_and_tag' do
73
+ it 'return posts that have authors and tags in common with `post` ordered by most in common first' do
74
+ expect(post.similar_posts_by_author_and_tag.map(&:similar_posts_by_author_and_tag_commonality_count)).to eq([5, 4, 1])
75
+ expect(post.similar_posts_by_author_and_tag).to eq([post1, post4, post2])
76
+ end
49
77
  end
50
78
  end
51
79
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: similar_models
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jolyon Pawlyn
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-02-24 00:00:00.000000000 Z
10
+ date: 2025-02-25 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -107,8 +107,9 @@ dependencies:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: '1.0'
110
- description: Adds an instance method to an active record model that returns the most
111
- similar models based on associated models in common
110
+ description: Adds a `similar_#{model_name.plural}` instance and class method to an
111
+ active record model and returns models based on associated models in common ordered
112
+ by most in common first
112
113
  email:
113
114
  - jpawlyn@gmail.com
114
115
  executables: []
@@ -148,7 +149,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
149
  requirements: []
149
150
  rubygems_version: 3.6.5
150
151
  specification_version: 4
151
- summary: Returns models that have the most associated models in common
152
+ summary: Returns models that have associated models in common ordered by most in common
153
+ first
152
154
  test_files:
153
155
  - spec/post_spec.rb
154
156
  - spec/spec_helper.rb