make_taggable 0.6.6 → 0.7.0
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 +4 -4
- data/.github/workflows/mysql_tests.yml +56 -0
- data/.github/workflows/pg_tests.yml +56 -0
- data/.github/workflows/{ci.yml → sqlite_tests.yml} +10 -10
- data/.github/workflows/standardrb-check.yml +37 -0
- data/.gitignore +3 -0
- data/Appraisals +4 -0
- data/README.md +47 -27
- data/Rakefile +1 -0
- data/lib/make_taggable.rb +3 -3
- data/lib/make_taggable/version.rb +1 -2
- data/lib/tasks/setup_test_db.rake +6 -0
- data/make_taggable.gemspec +10 -8
- data/spec/dummy/README.md +0 -24
- data/spec/dummy/app/models/cached_model_with_array.rb +0 -6
- data/spec/dummy/app/models/taggable_model_with_json.rb +6 -0
- data/spec/dummy/config/application.rb +2 -8
- data/spec/dummy/config/database.yml +1 -19
- data/spec/dummy/config/mysql_database.yml.ci +8 -0
- data/spec/dummy/config/pg_database.yml.ci +8 -0
- data/spec/dummy/db/migrate/{20201119220853_create_taggable_models.rb → 020201119220853_create_taggable_models.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221037_create_columns_override_models.rb → 020201119221037_create_columns_override_models.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221121_create_non_standard_id_taggable_models.rb → 020201119221121_create_non_standard_id_taggable_models.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221228_create_untaggable_models.rb → 020201119221228_create_untaggable_models.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221247_create_cached_models.rb → 020201119221247_create_cached_models.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221314_create_other_cached_models.rb → 020201119221314_create_other_cached_models.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221343_create_companies.rb → 020201119221343_create_companies.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221416_create_users.rb → 020201119221416_create_users.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221434_create_other_taggable_models.rb → 020201119221434_create_other_taggable_models.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221507_create_ordered_taggable_models.rb → 020201119221507_create_ordered_taggable_models.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221530_create_cache_methods_injected_models.rb → 020201119221530_create_cache_methods_injected_models.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221629_create_other_cached_with_array_models.rb → 020201119221629_create_other_cached_with_array_models.rb} +0 -0
- data/spec/dummy/db/migrate/{20201119221746_create_taggable_model_with_jsons.rb → 020201119221746_create_taggable_model_with_jsons.rb} +0 -0
- data/spec/make_taggable/tag_spec.rb +88 -250
- data/spec/make_taggable/taggable_spec.rb +1 -1
- data/spec/spec_helper.rb +0 -2
- metadata +77 -59
- data/.travis.yml +0 -36
- data/gemfiles/rails_5.gemfile +0 -9
- data/gemfiles/rails_6.gemfile +0 -9
- data/gemfiles/rails_master.gemfile +0 -9
- data/spec/dummy/db/migrate/20201121222007_create_make_taggable_tags.make_taggable_engine.rb +0 -11
- data/spec/dummy/db/migrate/20201121222008_create_make_taggable_taggings.make_taggable_engine.rb +0 -13
- data/spec/dummy/db/migrate/20201121222009_change_tag_name_collation_mysql.make_taggable_engine.rb +0 -8
- data/spec/dummy/db/migrate/20201121222010_add_index_to_tags.make_taggable_engine.rb +0 -6
- data/spec/dummy/db/migrate/20201121222011_add_index_to_taggings.make_taggable_engine.rb +0 -13
- data/spec/dummy/db/schema.rb +0 -117
- data/spec/dummy/db/seeds.rb +0 -7
data/spec/dummy/README.md
CHANGED
@@ -1,24 +0,0 @@
|
|
1
|
-
# README
|
2
|
-
|
3
|
-
This README would normally document whatever steps are necessary to get the
|
4
|
-
application up and running.
|
5
|
-
|
6
|
-
Things you may want to cover:
|
7
|
-
|
8
|
-
* Ruby version
|
9
|
-
|
10
|
-
* System dependencies
|
11
|
-
|
12
|
-
* Configuration
|
13
|
-
|
14
|
-
* Database creation
|
15
|
-
|
16
|
-
* Database initialization
|
17
|
-
|
18
|
-
* How to run the test suite
|
19
|
-
|
20
|
-
* Services (job queues, cache servers, search engines, etc.)
|
21
|
-
|
22
|
-
* Deployment instructions
|
23
|
-
|
24
|
-
* ...
|
@@ -5,15 +5,9 @@ require "rails/all"
|
|
5
5
|
Bundler.require(*Rails.groups)
|
6
6
|
require "make_taggable"
|
7
7
|
|
8
|
+
|
8
9
|
module Dummy
|
9
10
|
class Application < Rails::Application
|
10
|
-
|
11
|
-
# Application configuration can go into files in config/initializers
|
12
|
-
# -- all .rb files in that directory are automatically loaded after loading
|
13
|
-
# the framework and any gems in your application.
|
14
|
-
|
15
|
-
if Rails.gem_version < Gem::Version.new("6.0") && config.active_record.sqlite3
|
16
|
-
config.active_record.sqlite3.represent_boolean_as_integer = true
|
17
|
-
end
|
11
|
+
config.generators.system_tests = nil
|
18
12
|
end
|
19
13
|
end
|
@@ -1,25 +1,7 @@
|
|
1
|
-
|
2
|
-
# gem install sqlite3
|
3
|
-
#
|
4
|
-
# Ensure the SQLite 3 gem is defined in your Gemfile
|
5
|
-
# gem 'sqlite3'
|
6
|
-
#
|
1
|
+
---
|
7
2
|
default: &default
|
8
3
|
adapter: sqlite3
|
9
|
-
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
10
|
-
timeout: 5000
|
11
4
|
|
12
|
-
development:
|
13
|
-
<<: *default
|
14
|
-
database: db/development.sqlite3
|
15
|
-
|
16
|
-
# Warning: The database defined as "test" will be erased and
|
17
|
-
# re-generated from your development database when you run "rake".
|
18
|
-
# Do not set this db to the same as development or production.
|
19
5
|
test:
|
20
6
|
<<: *default
|
21
7
|
database: db/test.sqlite3
|
22
|
-
|
23
|
-
production:
|
24
|
-
<<: *default
|
25
|
-
database: db/production.sqlite3
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
data/spec/dummy/db/migrate/{20201119221416_create_users.rb → 020201119221416_create_users.rb}
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
@@ -1,297 +1,135 @@
|
|
1
|
-
|
1
|
+
module MakeTaggable
|
2
|
+
class Tag < ::ActiveRecord::Base
|
3
|
+
self.table_name = MakeTaggable.tags_table
|
2
4
|
|
3
|
-
|
4
|
-
before(:each) do
|
5
|
-
@tag = MakeTaggable::Tag.new
|
6
|
-
@user = TaggableModel.create(name: "Pablo")
|
7
|
-
end
|
5
|
+
### ASSOCIATIONS:
|
8
6
|
|
9
|
-
|
10
|
-
context "case insensitive collation and unique index on tag name", if: using_case_insensitive_collation? do
|
11
|
-
before(:each) do
|
12
|
-
MakeTaggable::Tag.create(name: "Awesome")
|
13
|
-
MakeTaggable::Tag.create(name: "epic")
|
14
|
-
end
|
7
|
+
has_many :taggings, dependent: :destroy, class_name: "::MakeTaggable::Tagging"
|
15
8
|
|
16
|
-
|
17
|
-
expect(MakeTaggable::Tag.named_like_any(%w[awesome epic]).count).to eq(2)
|
18
|
-
end
|
19
|
-
end
|
9
|
+
### VALIDATIONS:
|
20
10
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
MakeTaggable::Tag.create(name: "awesome")
|
25
|
-
MakeTaggable::Tag.create(name: "epic")
|
26
|
-
end
|
11
|
+
validates_presence_of :name
|
12
|
+
validates_uniqueness_of :name, if: :validates_name_uniqueness?
|
13
|
+
validates_length_of :name, maximum: 255
|
27
14
|
|
28
|
-
|
29
|
-
|
30
|
-
|
15
|
+
# monkey patch this method if don't need name uniqueness validation
|
16
|
+
def validates_name_uniqueness?
|
17
|
+
true
|
31
18
|
end
|
32
|
-
end
|
33
19
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
expect { MakeTaggable::Tag.named_any(["holä", "hol'ä"]) }.not_to raise_error
|
38
|
-
end
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
|
-
describe "for context" do
|
43
|
-
before(:each) do
|
44
|
-
@user.skill_list.add("ruby")
|
45
|
-
@user.save
|
46
|
-
end
|
47
|
-
|
48
|
-
it "should return tags that have been used in the given context" do
|
49
|
-
expect(MakeTaggable::Tag.for_context("skills").pluck(:name)).to include("ruby")
|
50
|
-
end
|
51
|
-
|
52
|
-
it "should not return tags that have been used in other contexts" do
|
53
|
-
expect(MakeTaggable::Tag.for_context("needs").pluck(:name)).to_not include("ruby")
|
54
|
-
end
|
55
|
-
end
|
56
|
-
|
57
|
-
describe "find or create by name" do
|
58
|
-
before(:each) do
|
59
|
-
@tag.name = "awesome"
|
60
|
-
@tag.save
|
61
|
-
end
|
20
|
+
### SCOPES:
|
21
|
+
scope :most_used, ->(limit = 20) { order("taggings_count desc").limit(limit) }
|
22
|
+
scope :least_used, ->(limit = 20) { order("taggings_count asc").limit(limit) }
|
62
23
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
end
|
70
|
-
|
71
|
-
it "should create by name" do
|
72
|
-
expect(-> {
|
73
|
-
MakeTaggable::Tag.find_or_create_with_like_by_name("epic")
|
74
|
-
}).to change(MakeTaggable::Tag, :count).by(1)
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
describe "find or create by unicode name", unless: using_sqlite? do
|
79
|
-
before(:each) do
|
80
|
-
@tag.name = "привет"
|
81
|
-
@tag.save
|
82
|
-
end
|
83
|
-
|
84
|
-
it "should find by name" do
|
85
|
-
expect(MakeTaggable::Tag.find_or_create_with_like_by_name("привет")).to eq(@tag)
|
24
|
+
def self.named(name)
|
25
|
+
if MakeTaggable.strict_case_match
|
26
|
+
where(["name = #{binary}?", as_8bit_ascii(name)])
|
27
|
+
else
|
28
|
+
where(["LOWER(name) = LOWER(?)", as_8bit_ascii(unicode_downcase(name))])
|
29
|
+
end
|
86
30
|
end
|
87
31
|
|
88
|
-
|
89
|
-
|
32
|
+
def self.named_any(list)
|
33
|
+
clause = list.map { |tag|
|
34
|
+
sanitize_sql_for_named_any(tag).force_encoding("BINARY")
|
35
|
+
}.join(" OR ")
|
36
|
+
where(clause)
|
90
37
|
end
|
91
38
|
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
expect(MakeTaggable::Tag.find_or_create_with_like_by_name("Iñupiat")).to eq(@tag)
|
39
|
+
def self.named_like(name)
|
40
|
+
clause = ["name #{MakeTaggable::Utils.like_operator} ? ESCAPE '!'", "%#{MakeTaggable::Utils.escape_like(name)}%"]
|
41
|
+
where(clause)
|
96
42
|
end
|
97
|
-
end
|
98
43
|
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
44
|
+
def self.named_like_any(list)
|
45
|
+
clause = list.map { |tag|
|
46
|
+
sanitize_sql(["name #{MakeTaggable::Utils.like_operator} ? ESCAPE '!'", "%#{MakeTaggable::Utils.escape_like(tag.to_s)}%"])
|
47
|
+
}.join(" OR ")
|
48
|
+
where(clause)
|
103
49
|
end
|
104
50
|
|
105
|
-
|
106
|
-
|
51
|
+
def self.for_context(context)
|
52
|
+
joins(:taggings)
|
53
|
+
.where(["#{MakeTaggable.taggings_table}.context = ?", context])
|
54
|
+
.select("DISTINCT #{MakeTaggable.tags_table}.*")
|
107
55
|
end
|
108
56
|
|
109
|
-
|
110
|
-
expect(MakeTaggable::Tag.find_or_create_all_with_like_by_name("AWESOME")).to eq([@tag])
|
111
|
-
end
|
57
|
+
### CLASS METHODS:
|
112
58
|
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
}.to change(MakeTaggable::Tag, :count).by(1)
|
59
|
+
def self.find_or_create_with_like_by_name(name)
|
60
|
+
if MakeTaggable.strict_case_match
|
61
|
+
find_or_create_all_with_like_by_name([name]).first
|
62
|
+
else
|
63
|
+
named_like(name).first || create(name: name)
|
119
64
|
end
|
120
65
|
end
|
121
66
|
|
122
|
-
|
123
|
-
|
124
|
-
MakeTaggable::Tag.find_or_create_all_with_like_by_name("epic")
|
125
|
-
}.to change(MakeTaggable::Tag, :count).by(1)
|
126
|
-
end
|
127
|
-
|
128
|
-
context "case sensitive" do
|
129
|
-
it "should find or create by name case sensitive" do
|
130
|
-
MakeTaggable.strict_case_match = true
|
131
|
-
expect {
|
132
|
-
expect(MakeTaggable::Tag.find_or_create_all_with_like_by_name("AWESOME", "awesome").map(&:name)).to eq(%w[AWESOME awesome])
|
133
|
-
}.to change(MakeTaggable::Tag, :count).by(1)
|
134
|
-
end
|
135
|
-
end
|
136
|
-
|
137
|
-
it "should find or create by name" do
|
138
|
-
expect {
|
139
|
-
expect(MakeTaggable::Tag.find_or_create_all_with_like_by_name("awesome", "epic").map(&:name)).to eq(%w[awesome epic])
|
140
|
-
}.to change(MakeTaggable::Tag, :count).by(1)
|
141
|
-
end
|
142
|
-
|
143
|
-
it "should return an empty array if no tags are specified" do
|
144
|
-
expect(MakeTaggable::Tag.find_or_create_all_with_like_by_name([])).to be_empty
|
145
|
-
end
|
146
|
-
end
|
147
|
-
|
148
|
-
it "should require a name" do
|
149
|
-
@tag.valid?
|
150
|
-
# TODO, we should find another way to check this
|
151
|
-
expect(@tag.errors[:name]).to eq(["can't be blank"])
|
152
|
-
|
153
|
-
@tag.name = "something"
|
154
|
-
@tag.valid?
|
67
|
+
def self.find_or_create_all_with_like_by_name(*list)
|
68
|
+
list = Array(list).flatten
|
155
69
|
|
156
|
-
|
157
|
-
end
|
158
|
-
|
159
|
-
it "should limit the name length to 255 or less characters" do
|
160
|
-
@tag.name = "fgkgnkkgjymkypbuozmwwghblmzpqfsgjasflblywhgkwndnkzeifalfcpeaeqychjuuowlacmuidnnrkprgpcpybarbkrmziqihcrxirlokhnzfvmtzixgvhlxzncyywficpraxfnjptxxhkqmvicbcdcynkjvziefqzyndxkjmsjlvyvbwraklbalykyxoliqdlreeykuphdtmzfdwpphmrqvwvqffojkqhlzvinqajsxbszyvrqqyzusxranr"
|
161
|
-
@tag.valid?
|
162
|
-
# TODO, we should find another way to check this
|
163
|
-
expect(@tag.errors[:name]).to eq(["is too long (maximum is 255 characters)"])
|
70
|
+
return [] if list.empty?
|
164
71
|
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
72
|
+
existing_tags = named_any(list)
|
73
|
+
list.map do |tag_name|
|
74
|
+
tries ||= 3
|
75
|
+
comparable_tag_name = comparable_name(tag_name)
|
76
|
+
existing_tag = existing_tags.find { |tag| comparable_name(tag.name) == comparable_tag_name }
|
77
|
+
existing_tag || create(name: tag_name)
|
78
|
+
rescue ActiveRecord::RecordNotUnique
|
79
|
+
if (tries -= 1).positive?
|
80
|
+
ActiveRecord::Base.connection.execute "ROLLBACK"
|
81
|
+
existing_tags = named_any(list)
|
82
|
+
retry
|
83
|
+
end
|
175
84
|
|
176
|
-
|
177
|
-
|
178
|
-
expect(@tag.to_s).to eq("cool")
|
179
|
-
end
|
180
|
-
|
181
|
-
it "have named_scope named(something)" do
|
182
|
-
@tag.name = "cool"
|
183
|
-
@tag.save!
|
184
|
-
expect(MakeTaggable::Tag.named("cool")).to include(@tag)
|
185
|
-
end
|
186
|
-
|
187
|
-
it "have named_scope named_like(something)" do
|
188
|
-
@tag.name = "cool"
|
189
|
-
@tag.save!
|
190
|
-
@another_tag = MakeTaggable::Tag.create!(name: "coolip")
|
191
|
-
expect(MakeTaggable::Tag.named_like("cool")).to include(@tag, @another_tag)
|
192
|
-
end
|
193
|
-
|
194
|
-
describe "escape wildcard symbols in like requests" do
|
195
|
-
before(:each) do
|
196
|
-
@tag.name = "cool"
|
197
|
-
@tag.save
|
198
|
-
@another_tag = MakeTaggable::Tag.create!(name: "coo%")
|
199
|
-
@another_tag2 = MakeTaggable::Tag.create!(name: "coolish")
|
85
|
+
raise DuplicateTagError.new("'#{tag_name}' has already been taken")
|
86
|
+
end
|
200
87
|
end
|
201
88
|
|
202
|
-
|
203
|
-
expect(MakeTaggable::Tag.named_like("coo%")).to_not include(@tag)
|
204
|
-
expect(MakeTaggable::Tag.named_like("coo%")).to include(@another_tag)
|
205
|
-
end
|
206
|
-
end
|
89
|
+
### INSTANCE METHODS:
|
207
90
|
|
208
|
-
|
209
|
-
|
210
|
-
MakeTaggable.strict_case_match = true
|
211
|
-
@tag.name = "awesome"
|
212
|
-
@tag.save!
|
91
|
+
def ==(other)
|
92
|
+
super || (other.is_a?(Tag) && name == other.name)
|
213
93
|
end
|
214
94
|
|
215
|
-
|
216
|
-
|
95
|
+
def to_s
|
96
|
+
name
|
217
97
|
end
|
218
98
|
|
219
|
-
|
220
|
-
|
99
|
+
def count
|
100
|
+
read_attribute(:count).to_i
|
221
101
|
end
|
222
102
|
|
223
|
-
|
224
|
-
|
225
|
-
expect {
|
226
|
-
MakeTaggable::Tag.find_or_create_with_like_by_name("AWESOME")
|
227
|
-
}.to change(MakeTaggable::Tag, :count)
|
103
|
+
class << self
|
104
|
+
private
|
228
105
|
|
229
|
-
|
106
|
+
def comparable_name(str)
|
107
|
+
if MakeTaggable.strict_case_match
|
108
|
+
str
|
109
|
+
else
|
110
|
+
unicode_downcase(str.to_s)
|
111
|
+
end
|
230
112
|
end
|
231
|
-
end
|
232
|
-
|
233
|
-
context "case sensitive" do
|
234
|
-
it "should have a named_scope named(something) that matches exactly" do
|
235
|
-
uppercase_tag = MakeTaggable::Tag.create(name: "Cool")
|
236
|
-
@tag.name = "cool"
|
237
|
-
@tag.save!
|
238
113
|
|
239
|
-
|
240
|
-
|
114
|
+
def binary
|
115
|
+
MakeTaggable::Utils.using_mysql? ? "BINARY " : nil
|
241
116
|
end
|
242
|
-
end
|
243
|
-
|
244
|
-
it "should not change encoding" do
|
245
|
-
name = "\u3042"
|
246
|
-
original_encoding = name.encoding
|
247
|
-
record = MakeTaggable::Tag.find_or_create_with_like_by_name(name)
|
248
|
-
record.reload
|
249
|
-
expect(record.name.encoding).to eq(original_encoding)
|
250
|
-
end
|
251
|
-
|
252
|
-
context "named any with some special characters combinations", if: using_mysql? do
|
253
|
-
it "should not raise an invalid encoding exception" do
|
254
|
-
expect { MakeTaggable::Tag.named_any(["holä", "hol'ä"]) }.not_to raise_error
|
255
|
-
end
|
256
|
-
end
|
257
|
-
end
|
258
117
|
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
before { MakeTaggable::Tag.create(name: "ror") }
|
263
|
-
|
264
|
-
context "when do need unique names" do
|
265
|
-
it "should run uniqueness validation" do
|
266
|
-
expect(duplicate_tag).to_not be_valid
|
118
|
+
def as_8bit_ascii(string)
|
119
|
+
string.to_s.mb_chars
|
267
120
|
end
|
268
121
|
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
expect(duplicate_tag.errors.size).to eq(1)
|
273
|
-
expect(duplicate_tag.errors.messages[:name]).to include("has already been taken")
|
122
|
+
def unicode_downcase(string)
|
123
|
+
as_8bit_ascii(string).downcase
|
274
124
|
end
|
275
|
-
end
|
276
|
-
end
|
277
125
|
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
126
|
+
def sanitize_sql_for_named_any(tag)
|
127
|
+
if MakeTaggable.strict_case_match
|
128
|
+
sanitize_sql(["name = #{binary}?", as_8bit_ascii(tag)])
|
129
|
+
else
|
130
|
+
sanitize_sql(["LOWER(name) = LOWER(?)", as_8bit_ascii(unicode_downcase(tag))])
|
131
|
+
end
|
284
132
|
end
|
285
133
|
end
|
286
|
-
|
287
|
-
it "should find the most popular tags" do
|
288
|
-
expect(MakeTaggable::Tag.most_used(3).first.name).to eq("golden_syrup")
|
289
|
-
expect(MakeTaggable::Tag.most_used(3).length).to eq(3)
|
290
|
-
end
|
291
|
-
|
292
|
-
it "should find the least popular tags" do
|
293
|
-
expect(MakeTaggable::Tag.least_used(3).first.name).to eq("sports")
|
294
|
-
expect(MakeTaggable::Tag.least_used(3).length).to eq(3)
|
295
|
-
end
|
296
134
|
end
|
297
135
|
end
|