metka 1.0.1 → 2.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ec15f83e345a5b29f191fc56a4362f248b0e09d24c6145c4420bb057888ebe8
4
- data.tar.gz: d2580715d9fb96319c5fbeed60767ab552c0009fc02d749a0a488f62297cb95f
3
+ metadata.gz: fe703d57e842a26673a9070675a1b6f20b0036baeca934e66a45f93135d90449
4
+ data.tar.gz: a53a0e1280a74db991e32f9c59c6b4477c176e56ad2c20e531de276c5521cdc4
5
5
  SHA512:
6
- metadata.gz: 822c1cf2a01b53c6ced44b6831d8de4caa173371fd02e7a03ad656458667c12fb93836538599313d0471b7d4718870357defc59ec5ae68c5ca81d54ef827e3a4
7
- data.tar.gz: 8a07ed56ce6d9efff2bfbc962f17fada5d5a4d4e5aee3d731b326dd1d0082c2f9126dba00ba1f807b3b38e0c496eedb4ea4cce97b2848b3d6051bdbb33c4d4d4
6
+ metadata.gz: b6d81f9b3de6aa00046b6871e4d42608dbf159cb851e07c7e1f2e4228df7b352248d08f7a17dbda88ca6383938b570dcffc0a4c3100a2e8bb7150a70dbf1cae5
7
+ data.tar.gz: b446651e3999627a6a9419c6018c100ae2e173a5103270d64bbd89cb777d16907c0d958486a59927732ec4e9af2e7a7447194eab564d61ed27f34ef752fe5fc9
@@ -22,7 +22,6 @@ before_install:
22
22
  - sudo service postgresql restart 11
23
23
 
24
24
  before_script:
25
- - gem update --system
26
25
  - psql -c 'CREATE ROLE travis SUPERUSER LOGIN CREATEDB;' -U postgres
27
26
  - ./bin/setup
28
27
 
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- metka (1.0.1)
4
+ metka (2.0.2)
5
5
  dry-configurable (>= 0.8)
6
6
  rails (>= 5.1)
7
7
 
@@ -53,15 +53,17 @@ GEM
53
53
  ast (2.4.0)
54
54
  builder (3.2.3)
55
55
  coderay (1.1.2)
56
- concurrent-ruby (1.1.5)
56
+ concurrent-ruby (1.1.6)
57
57
  crass (1.0.5)
58
58
  database_cleaner (1.7.0)
59
59
  diff-lcs (1.3)
60
- dry-configurable (0.9.0)
60
+ dry-configurable (0.11.3)
61
61
  concurrent-ruby (~> 1.0)
62
62
  dry-core (~> 0.4, >= 0.4.7)
63
+ dry-equalizer (~> 0.2)
63
64
  dry-core (0.4.9)
64
65
  concurrent-ruby (~> 1.0)
66
+ dry-equalizer (0.3.0)
65
67
  erubi (1.9.0)
66
68
  faker (2.8.0)
67
69
  i18n (>= 1.6, < 1.8)
@@ -84,7 +86,7 @@ GEM
84
86
  mini_portile2 (2.4.0)
85
87
  minitest (5.13.0)
86
88
  nio4r (2.5.2)
87
- nokogiri (1.10.7)
89
+ nokogiri (1.10.8)
88
90
  mini_portile2 (~> 2.4.0)
89
91
  parallel (1.19.1)
90
92
  parser (2.6.5.0)
@@ -93,7 +95,7 @@ GEM
93
95
  pry (0.12.2)
94
96
  coderay (~> 1.1.0)
95
97
  method_source (~> 0.9.0)
96
- rack (2.0.7)
98
+ rack (2.2.3)
97
99
  rack-test (1.1.0)
98
100
  rack (>= 1.0, < 3)
99
101
  rails (5.1.7)
@@ -157,7 +159,7 @@ GEM
157
159
  rubocop-rspec (1.35.0)
158
160
  rubocop (>= 0.60.0)
159
161
  ruby-progressbar (1.10.1)
160
- sprockets (4.0.0)
162
+ sprockets (4.0.2)
161
163
  concurrent-ruby (~> 1.0)
162
164
  rack (> 1, < 3)
163
165
  sprockets-rails (3.2.1)
@@ -175,7 +177,7 @@ GEM
175
177
  unicode-display_width (1.6.0)
176
178
  websocket-driver (0.6.5)
177
179
  websocket-extensions (>= 0.1.0)
178
- websocket-extensions (0.1.4)
180
+ websocket-extensions (0.1.5)
179
181
 
180
182
  PLATFORMS
181
183
  ruby
@@ -196,4 +198,4 @@ DEPENDENCIES
196
198
  timecop (>= 0.9)
197
199
 
198
200
  BUNDLED WITH
199
- 2.0.2
201
+ 2.1.4
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # Metka
6
6
 
7
- Rails gem to manage tags with SonggreSQL array columns.
7
+ Rails gem to manage tags with PostgreSQL array columns.
8
8
 
9
9
  ## Installation
10
10
 
@@ -43,11 +43,10 @@ end
43
43
 
44
44
  ```ruby
45
45
  class Song < ActiveRecord::Base
46
- include Metka::Model(column: 'tags')
47
- include Metka::Model(column: 'genres')
46
+ include Metka::Model(columns: %w[genres tags])
48
47
  end
49
48
 
50
- @song = Song.new(title: 'Migrate tags in Rails to SonggreSQL')
49
+ @song = Song.new(title: 'Migrate tags in Rails to PostgreSQL')
51
50
  @song.tag_list = 'top, chill'
52
51
  @song.genre_list = 'rock, jazz, pop'
53
52
  @song.save
@@ -58,7 +57,7 @@ end
58
57
  ### .with_all_#{column_name}
59
58
  ```ruby
60
59
  Song.with_all_tags('top')
61
- => [#<Song id: 1, title: 'Migrate tags in Rails to SonggreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
60
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
62
61
 
63
62
  Song.with_all_tags('top, 1990')
64
63
  => []
@@ -67,22 +66,22 @@ Song.with_all_tags('')
67
66
  => []
68
67
 
69
68
  Song.with_all_genres('rock')
70
- => [#<Song id: 1, title: 'Migrate tags in Rails to SonggreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
69
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
71
70
  ```
72
71
 
73
72
  ### .with_any_#{column_name}
74
73
  ```ruby
75
74
  Song.with_any_tags('chill')
76
- => [#<Song id: 1, title: 'Migrate tags in Rails to SonggreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
75
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
77
76
 
78
77
  Song.with_any_tags('chill, 1980')
79
- => [#<Song id: 1, title: 'Migrate tags in Rails to SonggreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
78
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
80
79
 
81
80
  Song.with_any_tags('')
82
81
  => []
83
82
 
84
83
  Song.with_any_genres('rock, rap')
85
- => [#<Song id: 1, title: 'Migrate tags in Rails to SonggreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
84
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
86
85
  ```
87
86
  ### .without_all_#{column_name}
88
87
  ```ruby
@@ -90,13 +89,13 @@ Song.without_all_tags('top')
90
89
  => []
91
90
 
92
91
  Song.without_all_tags('top, 1990')
93
- => [#<Song id: 1, title: 'Migrate tags in Rails to SonggreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
92
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
94
93
 
95
94
  Song.without_all_tags('')
96
- => [#<Song id: 1, title: 'Migrate tags in Rails to SonggreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
95
+ => []
97
96
 
98
97
  Song.without_all_genres('rock, pop')
99
- => [#<Song id: 1, title: 'Migrate tags in Rails to SonggreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
98
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
100
99
 
101
100
  Song.without_all_genres('rock')
102
101
  => []
@@ -108,23 +107,71 @@ Song.without_any_tags('top, 1990')
108
107
  => []
109
108
 
110
109
  Song.without_any_tags('1990, 1980')
111
- => [#<Song id: 1, title: 'Migrate tags in Rails to SonggreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
110
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
112
111
 
113
112
  Song.without_any_genres('rock, pop')
114
113
  => []
115
114
 
116
115
  Song.without_any_genres('')
117
- => [#<Song id: 1, title: 'Migrate tags in Rails to SonggreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
116
+ => []
117
+ ```
118
+
119
+ ### .tagged_with
120
+ ```ruby
121
+ Song.tagged_with('top')
122
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
123
+
124
+ Song.tagged_with('top, 1990')
125
+ => []
126
+
127
+ Song.tagged_with('')
128
+ => []
129
+
130
+ Song.tagged_with('rock')
131
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
132
+
133
+ Song.tagged_with('rock', join_operator: Metka::And)
134
+ => []
135
+
136
+ Song.tagged_with('chill', any: true)
137
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
138
+
139
+ Song.tagged_with('chill, 1980', any: true)
140
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
141
+
142
+ Song.tagged_with('', any: true)
143
+ => []
144
+
145
+ Song.tagged_with('rock, rap', any: true, on: ['genres'])
146
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
147
+
148
+ Song.without_all_tags('top')
149
+ => []
150
+
151
+ Song.tagged_with('top, 1990', exclude: true)
152
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
153
+
154
+ Song.tagged_with('', exclude: true)
155
+ => []
156
+
157
+ Song.tagged_with('top, 1990', any: true, exclude: true)
158
+ => []
159
+
160
+ Song.tagged_with('1990, 1980', any: true, exclude: true)
161
+ => [#<Song id: 1, title: 'Migrate tags in Rails to PostgreSQL', tags: ['top', 'chill'], genres: ['rock', 'jazz', 'pop']]
162
+
163
+ Song.without_any_genres('rock, pop')
164
+ => []
118
165
  ```
119
166
 
120
167
  ## Custom delimiter
121
168
  By default, a comma is used as a delimiter to create tags from a string.
122
169
  You can make your own custom separator:
123
170
  ```ruby
124
- Metka.config.delimiter = [',', ' ', '\|']
171
+ Metka.config.delimiter = '|'
125
172
  parsed_data = Metka::GenericParser.instance.call('cool, data|I have')
126
173
  parsed_data.to_a
127
- =>['cool', 'data', 'I', 'have']
174
+ =>['cool, data', 'I have']
128
175
  ```
129
176
 
130
177
  ## Tags with quote
@@ -136,11 +183,10 @@ parsed_data.to_a
136
183
 
137
184
  ## Custom parser
138
185
  By default we use [generic_parser](lib/metka/generic_parser.rb "generic_parser")
139
- If you want use your custom parser you can do:
186
+ If you want to use your custom parser you can do:
140
187
  ```ruby
141
188
  class Song < ActiveRecord::Base
142
- include Metka::Model(column: 'tags', parser: Your::Custom::Parser.instance)
143
- include Metka::Model(column: 'genres')
189
+ include Metka::Model(columns: %w[genres tags], parser: Your::Custom::Parser.instance)
144
190
  end
145
191
  ```
146
192
  Custom parser must be a singleton class that has a `.call` method that accepts the tag string
@@ -149,15 +195,35 @@ Custom parser must be a singleton class that has a `.call` method that accepts t
149
195
 
150
196
  There are several strategies to get tag statistics
151
197
 
198
+ ### ActiveRecord Strategy (Default)
199
+
200
+ Data about taggings is accessible via class methods of your model with `Metka::Model` attached. You can calculate a cloud for a single tagged column or multiple columns, the latter case would return to you a sum of taggings from multiple tagged columns, that are provided as arguments, for each tag present. ActiveRecord Strategy is an easiest way to implement, since it wouldn't require any additional code, but it's the slowest one on SELECT.
201
+
202
+ ```ruby
203
+ class Book < ActiveRecord::Base
204
+ include Metka::Model(column: 'authors')
205
+ include Metka::Model(column: 'co_authors')
206
+ end
207
+
208
+ tag_cloud = Book.author_cloud
209
+ => [["L.N. Tolstoy", 3], ["F.M. Dostoevsky", 6]]
210
+ genre_cloud = Book.co_author_cloud
211
+ => [["A.P. Chekhov", 5], ["N.V. Gogol", 8], ["L.N. Tolstoy", 2]]
212
+ summary_cloud = Book.metka_cloud('authors', 'co_authors')
213
+ => [["L.N. Tolstoy", 5], ["F.M. Dostoevsky", 6], ["A.P. Chekhov", 5], ["N.V. Gogol", 8]]
214
+ ```
215
+
152
216
  ### View Strategy
153
217
 
154
- Data about taggings will be agregated in SQL View. The easiest way to implement but the most slow on SELECT.
218
+ Data about taggings will be agregated in SQL View. Performance-wise that strategy has no benefits over ActiveRecord Strategy, but if you need to store tags aggregations in a distinct model, that's an easiest way to achieve it.
155
219
 
156
220
  ```bash
157
- rails g metka:strategies:view --source-table-name=NAME_OF_TABLE_WITH_TAGS
221
+ rails g metka:strategies:view --source-table-name=NAME_OF_TABLE_WITH_TAGS [--source-columns=NAME_OF_COLUMN_1 NAME_OF_COLUMN_2] [--view-name=NAME_OF_RESULTING_VIEW]
158
222
  ```
159
223
 
160
- The code above will generate a migration that creates view to store aggregated data about tag in `NAME_OF_TABLE_WITH_TAGS` table.
224
+ The code above will generate a migration that creates view with specified `NAME_OF_RESULTING_VIEW`, that would aggregate tags data from specified array of tagged columns [`NAME_OF_COLUMN_1`, `NAME_OF_COLUMN_2`, ...], that are present within specified table `NAME_OF_TABLE_WITH_TAGS`.
225
+ If `source-columns` option is not provided, then `tags` column would be used as defaults. If array of multiple values would be provided to the option, then the aggregation would be made with the tags from multiple tagged columns, so if a single tag would be found within multiple tagged columns, the resulting aggregation inside the view would have a single row for that tag with a sum of it's occurences across all stated tagged columns.
226
+ `view-name` option is also optional, it would just force the resulting view's name to the one of your choice. If it's not provided, then view name would be generated automatically, you could check it within generated migration.
161
227
 
162
228
  Lets take a look at real example. We have a `notes` table with `tags` column.
163
229
 
@@ -181,15 +247,18 @@ The result would be:
181
247
  class CreateTaggedNotesView < ActiveRecord::Migration[5.0]
182
248
  def up
183
249
  execute <<-SQL
184
- CREATE OR REPLACE VIEW tagged_notes AS
185
-
186
- SELECT UNNEST
187
- ( tags ) AS tag_name,
188
- COUNT ( * ) AS taggings_count
189
- FROM
190
- notes
191
- GROUP BY
192
- name;
250
+ CREATE OR REPLACE VIEW tagged_notes AS
251
+ SELECT
252
+ tag_name,
253
+ COUNT ( * ) AS taggings_count
254
+ FROM (
255
+ SELECT UNNEST
256
+ ( tags ) AS tag_name
257
+ FROM
258
+ view_posts
259
+ ) subquery
260
+ GROUP BY
261
+ tag_name;
193
262
  SQL
194
263
  end
195
264
 
@@ -215,33 +284,27 @@ Now you can create `TaggedNote` model and work with the view like you usually do
215
284
 
216
285
  ### Materialized View Strategy
217
286
 
218
- Similar to the strategy above, but the view will be Materialized and refreshed with the trigger
287
+ Data about taggings will be aggregated in SQL Materialized View, that would be refreshed with the trigger on each change of the tagged column's data. Except for the another type of view being used, that strategy behaves the same way, as a View Strategy above.
219
288
 
220
289
  ```bash
221
- rails g metka:strategies:materialized_view --source-table-name=NAME_OF_TABLE_WITH_TAGS
290
+ rails g metka:strategies:materialized_view --source-table-name=NAME_OF_TABLE_WITH_TAGS --source-columns=NAME_OF_COLUMN_1 NAME_OF_COLUMN_2 --view-name=NAME_OF_RESULTING_VIEW
222
291
  ```
223
292
 
224
- The code above will generate a migration that creates view to store aggregated data about tag in `NAME_OF_TABLE_WITH_TAGS` table.
225
-
226
- Lets take a look at real example. We have a `notes` table with `tags` column.
227
-
228
- | Column | Type | Default |
229
- |--------|---------------------|-----------------------------------|
230
- | id | integer | nextval('notes_id_seq'::regclass) |
231
- | body | text | |
232
- | tags | character varying[] | '{}'::character varying[] |
233
-
234
- Now lets generate a migration.
293
+ All of the options for that stategy's generation command are the same as for the View Strategy.
235
294
 
236
- ```bash
237
- rails g metka:strategies:materialized_view --source-table-name=notes
238
- ```
295
+ The migration template can be seen [here](spec/dummy/db/migrate/06_create_tagged_materialized_view_posts_materialized_view.rb "here")
239
296
 
240
- The migration code you can see [here](spec/dummy/db/migrate/05_create_tagged_materialized_view_Songs_materialized_view.rb "here")
297
+ With the same `notes` table with `tags` column the resulting view would have the same two columns
241
298
 
242
- Now lets take a look at `tagged_notes` materialized view.
299
+ | tag_name | taggings_count |
300
+ |----------|----------------|
301
+ | Ruby | 124056 |
302
+ | React | 30632 |
303
+ | Rails | 28696 |
304
+ | Crystal | 6566 |
305
+ | Elixir | 3475 |
243
306
 
244
- Now you can create `TaggedNote` model and work with the view like you usually do with Rails models.
307
+ And you can also create `TaggedNote` model to work with the view as with a Rails model.
245
308
 
246
309
  ### Table Strategy with Triggers
247
310
 
@@ -254,6 +317,128 @@ TBD
254
317
  2. [ActsAsTaggableArrayOn](https://github.com/tmiyamon/acts-as-taggable-array-on)
255
318
  3. [TagColumns](https://github.com/hopsoft/tag_columns)
256
319
 
320
+ ## Benchmark Comparison
321
+
322
+ There are some results of benchmarking a performance of write, read and find operations for different gems, that provide solution for tagging. Keep in mind, that those results can't be used as a proof, that some solution is better than the others, since each of the benchmarked gems has their unique features. You could run the benchmarks yourself or check, what exact operations has been used for benchmarking, with [MetkaBench application](https://github.com/jetrockets/metka_bench).
323
+
324
+ ```bash
325
+ $ rake bench:all
326
+ Deleted all MetkaSong
327
+ Deleted all ActsAsTaggableOn::Tagging
328
+ Deleted all ActsAsTaggableOn::Tag
329
+ Deleted all ActsAsTaggableSong
330
+ Deleted all ActsAsTaggableArraySong
331
+ Deleted all TagColumnsSong
332
+ Finished to clean
333
+
334
+ ###################################################################
335
+
336
+ bench:write
337
+
338
+ Time measurements:
339
+
340
+ Rehearsal ----------------------------------------------------------
341
+ Metka: 2.192410 0.161092 2.353502 ( 2.754766)
342
+ ActsAsTaggableOn: 13.769918 0.554951 14.324869 ( 16.990127)
343
+ ActsAsTaggableOnArray: 2.150441 0.154127 2.304568 ( 2.700022)
344
+ TagColumns: 2.202647 0.156162 2.358809 ( 2.753400)
345
+ ------------------------------------------------ total: 21.341748sec
346
+
347
+ user system total real
348
+ Metka: 2.137315 0.154046 2.291361 ( 2.643363)
349
+ ActsAsTaggableOn: 11.302848 0.448674 11.751522 ( 14.019458)
350
+ ActsAsTaggableOnArray: 2.143134 0.128655 2.271789 ( 2.670797)
351
+ TagColumns: 2.133780 0.125749 2.259529 ( 2.653404)
352
+
353
+ Memory measurements:
354
+
355
+ Calculating -------------------------------------
356
+ Metka: 179.064M memsize ( 0.000 retained)
357
+ 1.689M objects ( 0.000 retained)
358
+ 50.000 strings ( 0.000 retained)
359
+ ActsAsTaggableOn: 843.949M memsize ( 0.000 retained)
360
+ 8.550M objects ( 0.000 retained)
361
+ 50.000 strings ( 0.000 retained)
362
+ ActsAsTaggableOnArray: 178.807M memsize ( 0.000 retained)
363
+ 1.684M objects ( 0.000 retained)
364
+ 50.000 strings ( 0.000 retained)
365
+ TagColumns: 180.009M memsize ( 0.000 retained)
366
+ 1.699M objects ( 0.000 retained)
367
+ 50.000 strings ( 0.000 retained)
368
+
369
+ ###################################################################
370
+
371
+ bench:read
372
+
373
+ Time measurements:
374
+
375
+ Rehearsal ----------------------------------------------------------
376
+ Metka: 0.479695 0.044399 0.524094 ( 0.590616)
377
+ ActsAsTaggableOn: 2.436328 0.140581 2.576909 ( 3.096142)
378
+ ActsAsTaggableOnArray: 0.515198 0.042127 0.557325 ( 0.623205)
379
+ TagColumns: 0.518363 0.042661 0.561024 ( 0.626968)
380
+ ------------------------------------------------- total: 4.219352sec
381
+
382
+ user system total real
383
+ Metka: 0.446751 0.041886 0.488637 ( 0.554018)
384
+ ActsAsTaggableOn: 2.395166 0.164500 2.559666 ( 3.069655)
385
+ ActsAsTaggableOnArray: 0.439608 0.041682 0.481290 ( 0.544679)
386
+ TagColumns: 0.435404 0.041623 0.477027 ( 0.540359)
387
+
388
+ Memory measurements:
389
+
390
+ Calculating -------------------------------------
391
+ Metka: 42.291M memsize ( 0.000 retained)
392
+ 388.694k objects ( 0.000 retained)
393
+ 50.000 strings ( 0.000 retained)
394
+ ActsAsTaggableOn: 178.664M memsize ( 0.000 retained)
395
+ 1.812M objects ( 0.000 retained)
396
+ 50.000 strings ( 0.000 retained)
397
+ ActsAsTaggableOnArray: 42.173M memsize ( 0.000 retained)
398
+ 383.003k objects ( 0.000 retained)
399
+ 50.000 strings ( 0.000 retained)
400
+ TagColumns: 41.948M memsize ( 0.000 retained)
401
+ 383.003k objects ( 0.000 retained)
402
+ 50.000 strings ( 0.000 retained)
403
+
404
+ ###################################################################
405
+
406
+ bench:find_by_tag
407
+
408
+ Time measurements:
409
+
410
+ Rehearsal ----------------------------------------------------------
411
+ Metka: 0.029961 0.000059 0.030020 ( 0.030052)
412
+ ActsAsTaggableOn: 0.067095 0.000068 0.067163 ( 0.067205)
413
+ ActsAsTaggableOnArray: 0.043156 0.000133 0.043289 ( 0.043440)
414
+ TagColumns: 0.056475 0.000143 0.056618 ( 0.056697)
415
+ ------------------------------------------------- total: 0.197090sec
416
+
417
+ user system total real
418
+ Metka: 0.028291 0.000019 0.028310 ( 0.028321)
419
+ ActsAsTaggableOn: 0.065925 0.000036 0.065961 ( 0.065989)
420
+ ActsAsTaggableOnArray: 0.043214 0.000079 0.043293 ( 0.043361)
421
+ TagColumns: 0.056390 0.000160 0.056550 ( 0.056666)
422
+
423
+ Memory measurements:
424
+
425
+ Calculating -------------------------------------
426
+ Metka: 4.752M memsize ( 0.000 retained)
427
+ 43.000k objects ( 0.000 retained)
428
+ 1.000 strings ( 0.000 retained)
429
+ ActsAsTaggableOn: 8.967M memsize ( 0.000 retained)
430
+ 81.002k objects ( 0.000 retained)
431
+ 9.000 strings ( 0.000 retained)
432
+ ActsAsTaggableOnArray: 5.211M memsize ( 0.000 retained)
433
+ 57.003k objects ( 0.000 retained)
434
+ 6.000 strings ( 0.000 retained)
435
+ TagColumns: 6.696M memsize ( 0.000 retained)
436
+ 94.003k objects ( 0.000 retained)
437
+ 8.000 strings ( 0.000 retained)
438
+
439
+ Finished all benchmarks
440
+ ```
441
+
257
442
  ## Development
258
443
 
259
444
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -9,10 +9,15 @@ module Metka
9
9
  class MaterializedViewGenerator < ::Rails::Generators::Base # :nodoc:
10
10
  include Rails::Generators::Migration
11
11
 
12
+ DEFAULT_SOURCE_COLUMNS = ['tags'].freeze
13
+
12
14
  desc <<~LONGDESC
13
15
  Generates migration to implement view strategy for Metka
14
16
 
15
- > $ rails g metka:strategies:materialized_view --source-table-name=NAME_OF_TABLE_WITH_TAGS
17
+ > $ rails g metka:strategies:materialized_view \
18
+ --source-table-name=NAME_OF_TABLE_WITH_TAGS \
19
+ --source-columns=NAME_OF_TAGGED_COLUMN_1 NAME_OF_TAGGED_COLUMN_2 \
20
+ --view-name=NAME_OF_VIEW
16
21
  LONGDESC
17
22
 
18
23
  source_root File.expand_path('templates', __dir__)
@@ -20,8 +25,11 @@ module Metka
20
25
  class_option :source_table_name, type: :string, required: true,
21
26
  desc: 'Name of the table that has a column with tags'
22
27
 
23
- class_option :source_column_name, type: :string, default: 'tags',
24
- desc: 'Name of the column with stored tags'
28
+ class_option :source_columns, type: :array, default: DEFAULT_SOURCE_COLUMNS,
29
+ desc: 'List of the tagged columns names'
30
+
31
+ class_option :view_name, type: :string,
32
+ desc: 'Custom name for the resulting view'
25
33
 
26
34
  def generate_migration
27
35
  migration_template 'migration.rb.erb', "db/migrate/#{migration_name}.rb"
@@ -32,12 +40,19 @@ module Metka
32
40
  options[:source_table_name]
33
41
  end
34
42
 
35
- def source_column_name
36
- options[:source_column_name]
43
+ def source_columns
44
+ options[:source_columns]
45
+ end
46
+
47
+ def source_columns_names
48
+ source_columns.join('_and_')
37
49
  end
38
50
 
39
51
  def view_name
40
- "tagged_#{source_table_name}"
52
+ return options[:view_name] if options[:view_name]
53
+
54
+ columns_sequence = source_columns == DEFAULT_SOURCE_COLUMNS ? nil : "_with_#{source_columns_names}"
55
+ "tagged#{columns_sequence}_#{source_table_name}"
41
56
  end
42
57
 
43
58
  def migration_name
@@ -5,15 +5,18 @@ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VE
5
5
  execute <<-SQL
6
6
  CREATE OR REPLACE FUNCTION metka_refresh_<%= view_name %>_materialized_view() RETURNS trigger LANGUAGE plpgsql AS $$
7
7
  BEGIN
8
- IF TG_OP = 'INSERT' AND NEW.<%= source_column_name %> IS NOT NULL THEN
8
+ IF TG_OP = 'INSERT' AND
9
+ (<%= source_columns.map { |column| "NEW.#{column} IS NOT NULL" }.join(' OR ') %>) THEN
9
10
  REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
10
11
  END IF;
11
12
 
12
- IF TG_OP = 'UPDATE' AND OLD.<%= source_column_name %> != NEW.<%= source_column_name %> THEN
13
+ IF TG_OP = 'UPDATE' AND
14
+ (<%= source_columns.map { |column| "OLD.#{column} IS DISTINCT FROM NEW.#{column}" }.join(' OR ') %>) THEN
13
15
  REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
14
16
  END IF;
15
17
 
16
- IF TG_OP = 'DELETE' AND OLD.<%= source_column_name %> IS NOT NULL THEN
18
+ IF TG_OP = 'DELETE' AND
19
+ (<%= source_columns.map { |column| "OLD.#{column} IS NOT NULL" }.join(' OR ') %>) THEN
17
20
  REFRESH MATERIALIZED VIEW CONCURRENTLY <%= view_name %>;
18
21
  END IF;
19
22
  RETURN NEW;
@@ -21,17 +24,21 @@ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VE
21
24
 
22
25
  DROP MATERIALIZED VIEW IF EXISTS <%= view_name %>;
23
26
  CREATE MATERIALIZED VIEW <%= view_name %> AS
24
- SELECT UNNEST
25
- ( <%= source_column_name %> ) AS <%= source_column_name.singularize %>_name,
26
- COUNT ( * ) AS taggings_count
27
- FROM
28
- <%= source_table_name %>
27
+ SELECT
28
+ tag_name,
29
+ COUNT(*) AS taggings_count
30
+ FROM (
31
+ SELECT UNNEST
32
+ (<%= source_columns.join(' || ') %>) AS tag_name
33
+ FROM
34
+ <%= source_table_name %>
35
+ ) subquery
29
36
  GROUP BY
30
- <%= source_column_name.singularize %>_name;
37
+ tag_name;
31
38
 
32
- CREATE UNIQUE INDEX idx_<%= source_table_name %>_<%= source_column_name %> ON <%= view_name %>(<%= source_column_name.singularize %>_name);
39
+ CREATE UNIQUE INDEX idx_<%= source_table_name %>_<%= source_columns_names %> ON <%= view_name %>(tag_name);
33
40
 
34
- CREATE TRIGGER metka_on_<%= source_table_name %>
41
+ CREATE TRIGGER metka_on_<%= source_table_name %>_<%= source_columns_names %>
35
42
  AFTER UPDATE OR INSERT OR DELETE ON <%= source_table_name %> FOR EACH ROW
36
43
  EXECUTE PROCEDURE metka_refresh_<%= view_name %>_materialized_view();
37
44
  SQL
@@ -39,7 +46,7 @@ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VE
39
46
 
40
47
  def down
41
48
  execute <<-SQL
42
- DROP TRIGGER IF EXISTS metka_on_<%= source_table_name %> ON <%= source_table_name %>;
49
+ DROP TRIGGER IF EXISTS metka_on_<%= source_table_name %>_<%= source_columns_names %> ON <%= source_table_name %>;
43
50
  DROP FUNCTION IF EXISTS metka_refresh_<%= view_name %>_materialized_view;
44
51
  DROP MATERIALIZED VIEW IF EXISTS <%= view_name %>;
45
52
  SQL
@@ -4,14 +4,17 @@ class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VE
4
4
  def up
5
5
  execute <<-SQL
6
6
  CREATE OR REPLACE VIEW <%= view_name %> AS
7
-
8
- SELECT UNNEST
9
- ( <%= source_column_name %> ) AS <%= source_column_name.singularize %>_name,
10
- COUNT ( * ) AS taggings_count
11
- FROM
12
- <%= source_table_name %>
13
- GROUP BY
14
- <%= source_column_name.singularize %>_name;
7
+ SELECT
8
+ tag_name,
9
+ COUNT(*) AS taggings_count
10
+ FROM (
11
+ SELECT UNNEST
12
+ (<%= source_columns.join(' || ') %>) AS tag_name
13
+ FROM
14
+ <%= source_table_name %>
15
+ ) subquery
16
+ GROUP BY
17
+ tag_name;
15
18
  SQL
16
19
  end
17
20
 
@@ -9,6 +9,8 @@ module Metka
9
9
  class ViewGenerator < ::Rails::Generators::Base # :nodoc:
10
10
  include Rails::Generators::Migration
11
11
 
12
+ DEFAULT_SOURCE_COLUMNS = ['tags'].freeze
13
+
12
14
  desc <<~LONGDESC
13
15
  Generates migration to implement view strategy for Metka
14
16
 
@@ -20,8 +22,11 @@ module Metka
20
22
  class_option :source_table_name, type: :string, required: true,
21
23
  desc: 'Name of the table that has a column with tags'
22
24
 
23
- class_option :source_column_name, type: :string, default: 'tags',
24
- desc: 'Name of the column with stored tags'
25
+ class_option :source_columns, type: :array, default: DEFAULT_SOURCE_COLUMNS,
26
+ desc: 'List of the tagged columns names'
27
+
28
+ class_option :view_name, type: :string,
29
+ desc: 'Custom name for the resulting view'
25
30
 
26
31
  def generate_migration
27
32
  migration_template 'migration.rb.erb', "db/migrate/#{migration_name}.rb"
@@ -32,12 +37,19 @@ module Metka
32
37
  options[:source_table_name]
33
38
  end
34
39
 
35
- def source_column_name
36
- options[:source_column_name]
40
+ def source_columns
41
+ options[:source_columns]
42
+ end
43
+
44
+ def source_columns_names
45
+ source_columns.join('_and_')
37
46
  end
38
47
 
39
48
  def view_name
40
- "tagged_#{source_table_name}"
49
+ return options[:view_name] if options[:view_name]
50
+
51
+ columns_sequence = source_columns == DEFAULT_SOURCE_COLUMNS ? nil : "_with_#{source_columns_names}"
52
+ "tagged#{columns_sequence}_#{source_table_name}"
41
53
  end
42
54
 
43
55
  def migration_name
@@ -16,5 +16,5 @@ module Metka
16
16
  extend Dry::Configurable
17
17
 
18
18
  setting :parser, Metka::GenericParser
19
- setting :delimiter, ','
19
+ setting :delimiter, ',', reader: true
20
20
  end
@@ -12,6 +12,11 @@ module Metka
12
12
  class GenericParser
13
13
  include Singleton
14
14
 
15
+ def initialize
16
+ @single_quote_pattern ||= {}
17
+ @double_quote_pattern ||= {}
18
+ end
19
+
15
20
  def call(value)
16
21
  TagList.new.tap do |tag_list|
17
22
  case value
@@ -20,7 +25,7 @@ module Metka
20
25
  gsub_quote_pattern!(tag_list, value, double_quote_pattern)
21
26
  gsub_quote_pattern!(tag_list, value, single_quote_pattern)
22
27
 
23
- tag_list.merge value.split(Regexp.new joined_delimiter).map(&:strip).reject(&:empty?)
28
+ tag_list.merge value.split(Regexp.new delimiter).map(&:strip).reject(&:empty?)
24
29
  when Enumerable
25
30
  tag_list.merge value.reject(&:empty?)
26
31
  end
@@ -36,17 +41,16 @@ module Metka
36
41
  }
37
42
  end
38
43
 
39
- def joined_delimiter
40
- delimeter = Metka.config.delimiter
41
- delimeter.is_a?(Array) ? delimeter.join('|') : delimeter
44
+ def delimiter
45
+ Metka.delimiter
42
46
  end
43
47
 
44
48
  def single_quote_pattern
45
- /(\A|#{joined_delimiter})\s*'(.*?)'\s*(?=#{joined_delimiter}\s*|\z)/
49
+ @single_quote_pattern[delimiter] ||= /(\A|#{delimiter})\s*'(.*?)'\s*(?=#{delimiter}\s*|\z)/
46
50
  end
47
51
 
48
52
  def double_quote_pattern
49
- /(\A|#{joined_delimiter})\s*"(.*?)"\s*(?=#{joined_delimiter}\s*|\z)/
53
+ @double_quote_pattern[delimiter] ||= /(\A|#{delimiter})\s*"(.*?)"\s*(?=#{delimiter}\s*|\z)/
50
54
  end
51
55
  end
52
56
  end
@@ -1,47 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'arel'
4
+
3
5
  module Metka
4
- def self.Model(column:, **options)
5
- Metka::Model.new(column: column, **options)
6
+ OR = Arel::Nodes::Or
7
+ AND = Arel::Nodes::And
8
+
9
+ def self.Model(column: nil, columns: nil, **options)
10
+ columns = [column, *columns].uniq.compact
11
+ raise ArgumentError, 'Columns not specified' unless columns.present?
12
+
13
+ Metka::Model.new(columns: columns, **options)
6
14
  end
7
15
 
8
16
  class Model < Module
9
- def initialize(column: , **options)
10
- @column = column
11
- @options = options
17
+ def initialize(columns:, **options)
18
+ @columns = columns.dup.freeze
19
+ @options = options.dup.freeze
12
20
  end
13
21
 
14
22
  def included(base)
15
- column = @column
23
+ columns = @columns
16
24
  parser = ->(tags) {
17
25
  @options[:parser] ? @options[:parser].call(tags) : Metka.config.parser.instance.call(tags)
18
26
  }
19
27
 
20
- search_by_tags = ->(model, tags, column, **options) {
28
+ # @param model [ActiveRecord::Base] model on which to execute search
29
+ # @param tags [Object] list of tags, representation depends on parser used
30
+ # @param options [Hash] options
31
+ # @option :join_operator [Metka::AND, Metka::OR]
32
+ # @option :on [Array<String>] list of column names to include in query
33
+ # @returns ViewPost::ActiveRecord_Relation
34
+ tagged_with_lambda = ->(model, tags, **options) {
35
+ cols = options.delete(:on)
21
36
  parsed_tag_list = parser.call(tags)
22
- if options[:without].present?
23
- model.where.not(::Metka::QueryBuilder.new.call(model, column, parsed_tag_list, options))
24
- else
25
- return model.none if parsed_tag_list.empty?
26
- model.where(::Metka::QueryBuilder.new.call(model, column, parsed_tag_list, options))
27
- end
37
+
38
+ return model.none if parsed_tag_list.empty?
39
+
40
+ request = ::Metka::QueryBuilder.new.call(model, cols, parsed_tag_list, options)
41
+ model.where(request)
28
42
  }
29
43
 
30
44
  base.class_eval do
31
- scope "with_all_#{column}", ->(tags) { search_by_tags.call(self, tags, column) }
32
- scope "with_any_#{column}", ->(tags) { search_by_tags.call(self, tags, column, { any: true }) }
33
- scope "without_all_#{column}", ->(tags) { search_by_tags.call(self, tags, column, { exclude_all: true, without: true }) }
34
- scope "without_any_#{column}", ->(tags) { search_by_tags.call(self, tags, column, { exclude_any: true, without: true }) }
45
+ columns.each do |column|
46
+ scope "with_all_#{column}", ->(tags) { tagged_with(tags, on: [ column ]) }
47
+ scope "with_any_#{column}", ->(tags) { tagged_with(tags, on: [ column ], any: true) }
48
+ scope "without_all_#{column}", ->(tags) { tagged_with(tags, on: [ column ], exclude: true) }
49
+ scope "without_any_#{column}", ->(tags) { tagged_with(tags, on: [ column ], any: true, exclude: true) }
50
+ end
51
+
52
+ unless respond_to?(:tagged_with)
53
+ scope :tagged_with, ->(tags = '', options = {}) {
54
+ options[:join_operator] ||= ::Metka::OR
55
+ options = {any: false}.merge(options)
56
+ options[:on] ||= columns
57
+
58
+ tagged_with_lambda.call(self, tags, **options)
59
+ }
60
+ end
35
61
  end
36
62
 
37
- base.define_method(column.singularize + '_list=') do |v|
38
- self.write_attribute(column, parser.call(v).to_a)
39
- self.write_attribute(column, nil) if self.send(column).empty?
63
+ base.define_singleton_method :metka_cloud do |*columns|
64
+ return [] if columns.blank?
65
+
66
+ prepared_unnest = columns.map { |column| "#{table_name}.#{column}" }.join(' || ')
67
+ subquery = all.select("UNNEST(#{prepared_unnest}) AS tag_name")
68
+
69
+ unscoped.from(subquery).group(:tag_name).pluck(:tag_name, 'COUNT(*) AS taggings_count')
40
70
  end
41
71
 
42
- base.define_method(column.singularize + '_list') do
43
- parser.call(self.send(column))
72
+ columns.each do |column|
73
+ base.define_method(column.singularize + '_list=') do |v|
74
+ write_attribute(column, parser.call(v).to_a)
75
+ write_attribute(column, nil) if send(column).empty?
76
+ end
77
+
78
+ base.define_method(column.singularize + '_list') do
79
+ parser.call(send(column))
80
+ end
81
+
82
+ base.define_singleton_method :"#{column.singularize}_cloud" do
83
+ metka_cloud(column)
84
+ end
44
85
  end
45
86
  end
46
87
  end
47
- end
88
+ end
@@ -1,23 +1,68 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'arel'
3
4
  require_relative 'query_builder/base_query'
4
- require_relative 'query_builder/exclude_all_tags_query'
5
- require_relative 'query_builder/exclude_any_tags_query'
6
5
  require_relative 'query_builder/any_tags_query'
7
6
  require_relative 'query_builder/all_tags_query'
8
7
 
9
8
  module Metka
10
9
  class QueryBuilder
11
- def call(taggable_model, column, tag_list, options)
12
- if options[:exclude_all].present?
13
- ExcludeAllTagsQuery.instance.call(taggable_model, column, tag_list)
14
- elsif options[:exclude_any].present?
15
- ExcludeAnyTagsQuery.instance.call(taggable_model, column, tag_list)
16
- elsif options[:any].present?
17
- AnyTagsQuery.instance.call(taggable_model, column, tag_list)
10
+ def call(model, columns, tags, options)
11
+ strategy = options_to_strategy(options)
12
+
13
+ query = join(options[:join_operator]) do
14
+ columns.map do |column|
15
+ build_query(strategy, model, column, tags)
16
+ end
17
+ end
18
+
19
+ if options[:exclude].present?
20
+ Arel::Nodes::Not.new(query)
21
+ else
22
+ query
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def options_to_strategy options
29
+ if options[:any].present?
30
+ AnyTagsQuery
18
31
  else
19
- AllTagsQuery.instance.call(taggable_model, column, tag_list)
32
+ AllTagsQuery
33
+ end
34
+ end
35
+
36
+ def join(operator, &block)
37
+ nodes = block.call
38
+
39
+ if operator == ::Metka::AND
40
+ join_and(nodes)
41
+ elsif operator == ::Metka::OR
42
+ join_or(nodes)
43
+ end
44
+ end
45
+
46
+ # @param nodes [Array<Arel::Node>, Arel::Node]
47
+ # @return [Arel::Node]
48
+ def join_or(nodes)
49
+ case nodes
50
+ when ::Arel::Node
51
+ nodes
52
+ when Array
53
+ l, *r = nodes
54
+ return l if r.empty?
55
+
56
+ l.or(join_or(r))
20
57
  end
21
58
  end
59
+
60
+ def join_and(queries)
61
+ Arel::Nodes::And.new(queries)
62
+ end
63
+
64
+ def build_query(strategy, model, column, tags)
65
+ strategy.instance.call(model, column, tags)
66
+ end
22
67
  end
23
68
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Metka
4
- VERSION = '1.0.1'
4
+ VERSION = '2.0.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metka
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Alexandrov
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2019-12-14 00:00:00.000000000 Z
12
+ date: 2020-07-08 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: dry-configurable
@@ -228,8 +228,6 @@ files:
228
228
  - lib/metka/query_builder/all_tags_query.rb
229
229
  - lib/metka/query_builder/any_tags_query.rb
230
230
  - lib/metka/query_builder/base_query.rb
231
- - lib/metka/query_builder/exclude_all_tags_query.rb
232
- - lib/metka/query_builder/exclude_any_tags_query.rb
233
231
  - lib/metka/tag_list.rb
234
232
  - lib/metka/version.rb
235
233
  - metka.gemspec
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Metka
4
- class ExcludeAllTagsQuery< BaseQuery
5
- private
6
-
7
- def infix_operator
8
- '@>'
9
- end
10
- end
11
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Metka
4
- class ExcludeAnyTagsQuery< BaseQuery
5
- private
6
-
7
- def infix_operator
8
- '&&'
9
- end
10
- end
11
- end