pg_tags_on 0.1.4 → 1.0.0

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: aac909ac252ee49fd469857036866fb22fbc78db87e19f84f8448637a780af6c
4
- data.tar.gz: 4ba45da54dc2782a11ce1cb67b5e61077447f544fcac29c0620c4e261d572ee0
3
+ metadata.gz: cf52d9810e0c765b90218fc5ef9f4bbd5ceb3f9f092e7fa4ade42d946165a8a1
4
+ data.tar.gz: 9911f4f06c06b5f3673220292b6cc0e36122a5ad2a86b3d587ed2565e8c66080
5
5
  SHA512:
6
- metadata.gz: 19090081b0fe790204a5384bc85c9e90d29ea824eeb0ace2b103e6a68010379f65fd05e0deef79474a7bbfaa6fdb01b59aaf62d151af2f48317f502a70b43a4c
7
- data.tar.gz: 0d2da6a30730fc639b915ceaf88a47bc6219d9ef111188a8f935f39aae936f6afbb36afbbef624ba6fb4b7f35eede5c89c3d0b3b917e526794c73cc16fa988a0
6
+ metadata.gz: 572db674007f2555744fb8420a847ae813caac80a2f9a803399f90207be5bf0f1c54efc536259d5395eb69248bba8af76ae345623e0972cebe782084d397132f
7
+ data.tar.gz: 8731695a2ac40afcba1dcf22d410fb78234b29ff67033d443a121ff2baf4838fab4d9859ac670d5713d718d91d416177246c5e50b5b08e4e066594ff97cc5727
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
- ## 0.1.0 ( 2020-03-11 ) ##
1
+ # Changelog
2
+
3
+ ## [0.4.0] - 2020-06-02
4
+ - bump gems versions
5
+ - ISSUE_TEMPLATE.md
6
+
7
+ ## [0.3.0] - 2020-04-15
8
+ - apply PR for gem versions
9
+ - fixed Arel deprecation warning
10
+ - changed validation param names
11
+
12
+ ## [0.2.0] - 2020-09-02
13
+ - Added support for multiple tags creation and deletion
14
+ - Optimizations
15
+ - Updated specs and documentation
16
+
17
+ ## [0.1.4] - 2020-04-12
18
+ - Added support for returning columns after create,update or delete tags operations
19
+ - Updated specs
20
+ - Updated documentation
21
+
22
+ ## [0.1.0] - 2020-03-11
23
+ - First release
2
24
 
3
- * First release
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # PgTagsOn
2
2
 
3
- ```pg_tags_on``` is a gem that makes working with tags stored in a Postgresql column easy. Supported column types are: ```character varying[]```, ```text[]```, ```integer[]``` and ```jsonb[]```.
3
+ ```pg_tags_on``` - keep tags in a column. Supported column types are: ```character varying[]```, ```text[]```, ```integer[]``` and ```jsonb[]```.
4
4
 
5
5
 
6
6
  Requirements:
@@ -15,7 +15,7 @@ Note: this gem is in early stage of development.
15
15
  - [Installation](#installation)
16
16
  - [Usage](#usage)
17
17
  - [ActiveRecord model setup](#activerecord-model-setup)
18
- - [Records queries](#records-queries)
18
+ - [Records queries](#queries)
19
19
  - [Tags](#tags)
20
20
  - [Set record's tags](#set-records-tags)
21
21
  - [Configuration](#configuration)
@@ -42,7 +42,7 @@ Or install it yourself as:
42
42
  ## Usage
43
43
  ### ActiveRecord model setup
44
44
 
45
- One or multiple columns can be specified:
45
+ One or multiple columns that store tags can be specified:
46
46
 
47
47
  ```ruby
48
48
  class Entity < ActiveRecord::Base
@@ -51,7 +51,7 @@ class Entity < ActiveRecord::Base
51
51
  end
52
52
  ```
53
53
 
54
- For ```jsonb[]``` columns you'll have to specify the key for the tag value. If you store multiple attributes in the objects then you must set also ```has_attributes: true```.
54
+ In ```jsonb[]``` columns, by default each tag is stored as an object with a single key. For example ```{"tag": "rubygems"}```. If you store multiple attributes in the objects, then you must set also ```has_attributes: true```.
55
55
 
56
56
  ```ruby
57
57
  class Entity < ActiveRecord::Base
@@ -65,7 +65,7 @@ Maximum number of tags and maximum tag length can be validated. Errors will be i
65
65
 
66
66
  ```ruby
67
67
  class Entity < ActiveRecord::Base
68
- pg_tags_on :tags, limit: 10, tag_length: 50 # limit to 10 tags and 50 chars. per tag.
68
+ pg_tags_on :tags, limit: 10, length: 50 # limit to 10 tags and 50 chars. per tag.
69
69
  end
70
70
  ```
71
71
 
@@ -140,20 +140,22 @@ Entity.tags.taggings
140
140
  => [#<PgTagsOn::Tag name: "alpha", entity_id: 1>, #<PgTagsOn::Tag name: "beta", entity_id: 1>, #<PgTagsOn::Tag name: "alpha", entity_id: 2>, ... ]
141
141
  ```
142
142
 
143
- Create, update and delete methods are using, for performance reasons, Postgresql functions to manipulate the arrays, so ActiveRecord models are not instantiated. A frequent problem is to ensure uniqueness of the tags for a record, but this can be achieved at the database level by creating a before create/update row trigger.
143
+ Create, update and delete methods are using, for performance reasons, Postgresql functions to manipulate the arrays, so ActiveRecord models are not instantiated. A frequent problem is to ensure uniqueness of the tags for a record, and this can be achieved at the database level by creating a before create/update row trigger.
144
144
 
145
145
  ```ruby
146
146
  # create
147
- Entity.tags.create('alpha') # add tag to all records
148
- Entity.where(...).tags.create('alpha') # add tag to filtered records
147
+ Entity.tags.create('alpha') # add tag to all records
148
+ Entity.tags.create(%w[alpha beta gamma]) # add many tags to all records
149
+ Entity.where(...).tags.create('alpha') # add tag to filtered records
149
150
 
150
151
  # update
151
152
  Entity.tags.update('alpha', 'a') # rename tag for all records
152
153
  Entity.where(...).tags.update('alpha', 'a') # rename tag for filtered records
153
154
 
154
155
  # delete
155
- Entity.tags.delete('alpha') # delete tag from all records
156
- Entity.where(...).tags.delete('alpha') # delete tag from filtered records
156
+ Entity.tags.delete('alpha') # delete tag from all records
157
+ Entity.tags.delete(%w[alpha beta gamma]) # delete many tags from all records
158
+ Entity.where(...).tags.delete('alpha') # delete tag from filtered records
157
159
 
158
160
  # any of these methods accepts :returning option
159
161
  Entity.tags.update('alpha', 'a', returning: %w[id tags])
@@ -12,7 +12,7 @@ module PgTagsOn
12
12
 
13
13
  pg_tags_on_init_model unless @pg_tags_on_init_model
14
14
  pg_tags_on_settings[name] = options.deep_symbolize_keys
15
- validates(name, 'pg_tags_on/tags': true) if %i[limit tag_length].any? { |k| options[k] }
15
+ validates(name, 'pg_tags_on/tags': true) if %i[limit length].any? { |k| options[k] }
16
16
  instance_eval <<-RUBY, __FILE__, __LINE__ + 1
17
17
  scope :#{name}, -> { PgTagsOn::Repository.new(self, "#{name}") }
18
18
  RUBY
@@ -7,7 +7,7 @@ module PgTagsOn
7
7
  # Transforms value in Hash if :key option is set
8
8
  def value
9
9
  @value ||= begin
10
- return query_value unless key?
10
+ return query_value unless nested?
11
11
 
12
12
  query_value.each.map do |val|
13
13
  key.reverse.inject(val) { |a, n| { n => a } }
@@ -23,7 +23,7 @@ module PgTagsOn
23
23
  @key ||= Array.wrap(settings[:key])
24
24
  end
25
25
 
26
- def key?
26
+ def nested?
27
27
  key.present?
28
28
  end
29
29
  end
@@ -13,11 +13,9 @@ module PgTagsOn
13
13
  }.freeze
14
14
 
15
15
  def left
16
- node = arel_function('array_to_json', attribute)
17
- node = arel_cast(node, 'jsonb')
18
- node = arel_function('jsonb_path_query_array', node, arel_build_quoted("$[*].#{key.join('.')}"))
19
-
20
- node
16
+ arel_function 'jsonb_path_query_array',
17
+ arel_cast(arel_function('array_to_json', attribute), 'jsonb'),
18
+ arel_build_quoted("$[*].#{key.join('.')}")
21
19
  end
22
20
 
23
21
  def right
@@ -2,7 +2,7 @@
2
2
 
3
3
  module PgTagsOn
4
4
  # Models' predicate handlers register this class
5
- class PredicateHandler < ::ActiveRecord::PredicateBuilder::BaseHandler
5
+ class PredicateHandler < ::ActiveRecord::PredicateBuilder::BasicObjectHandler
6
6
  def call(attribute, query)
7
7
  handler = Builder.new(attribute, query, predicate_builder).call
8
8
 
@@ -21,7 +21,7 @@ module PgTagsOn
21
21
  if column.array?
22
22
  array_handler
23
23
  else
24
- BaseHandler.new(attribute, query, predicate_builder)
24
+ BasicObjectHandler.new attribute, query, predicate_builder
25
25
  end
26
26
  end
27
27
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgTagsOn
4
+ module Repositories
5
+ module ArrayJsonb
6
+ # This class is respnsible to create tags stored as JSON objects.
7
+ class Create
8
+ include ::PgTagsOn::ActiveRecord::Arel
9
+
10
+ delegate :column_name, :arel_column, :bind_for, to: :repository
11
+
12
+ def call(repository:, relation:, tags:, returning: nil)
13
+ @repository = repository
14
+ value = arel_array_cat arel_column, bind_for(Array.wrap(tags))
15
+
16
+ repository.update_attributes relation: relation,
17
+ attributes: { column_name => value },
18
+ returning: returning
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :repository
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgTagsOn
4
+ module Repositories
5
+ module ArrayJsonb
6
+ # This class is respnsible to delete tags stored as JSON objects.
7
+ class Delete
8
+ include ::PgTagsOn::ActiveRecord::Arel
9
+
10
+ delegate :column_name, :tag_alias,
11
+ :arel_column, :bind_for, to: :repository
12
+
13
+ def call(repository:, relation:, tags:, returning: nil)
14
+ @repository = repository
15
+ @tags = tags
16
+
17
+ repository.update_attributes relation: relation,
18
+ attributes: { column_name => arel_function('array', active_tags) },
19
+ returning: returning
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :repository, :tags
25
+
26
+ ##
27
+ # For each record explode tags and filter out tags to be removed
28
+ #
29
+ def active_tags
30
+ query = Arel::SelectManager.new
31
+ .project(tag_alias)
32
+ .from(Arel::SelectManager.new
33
+ .project(arel_unnest(arel_column).as(tag_alias))
34
+ .as('update_tags'))
35
+ Array.wrap(tags).each do |tag|
36
+ query.where arel_infix_operation('@>', Arel.sql(tag_alias), bind_for(tag.to_json, nil)).not
37
+ end
38
+
39
+ query
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgTagsOn
4
+ module Repositories
5
+ module ArrayJsonb
6
+ # This class is respnsible to update tags stored as JSON objects.
7
+ class Update
8
+ include ::PgTagsOn::ActiveRecord::Arel
9
+
10
+ delegate :table_name, :column_name, :tag_alias,
11
+ :arel_table, :arel_column, :cast_type, :bind_for, to: :repository
12
+
13
+ def call(repository:, relation:, tag:, new_tag:, returning: nil)
14
+ @repository = repository
15
+ @relation = relation
16
+ sql = <<-SQL
17
+ WITH tag_positions AS ( #{select_tag_positions(tag).to_sql} )
18
+ UPDATE #{table_name}
19
+ SET #{column_name}[index] = #{column_name}[index] || $2
20
+ FROM tag_positions
21
+ WHERE #{table_name}.id = tag_positions.id
22
+ SQL
23
+ sql += " RETURNING #{returning}" if returning.present?
24
+ bindings = [
25
+ arel_query_attribute(arel_column, tag.to_json, cast_type),
26
+ arel_query_attribute(arel_column, new_tag.to_json, cast_type)
27
+ ]
28
+
29
+ result = relation.connection.exec_query(sql, 'SQL', bindings).rows
30
+
31
+ returning ? result : true
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :repository, :relation
37
+
38
+ ##
39
+ # For each record explode tags and their position index
40
+ # and select only occurences of the given tag value
41
+ #
42
+ # @param tag [String|Hash] Normalized tag value.
43
+ #
44
+ def select_tag_positions(tag)
45
+ condition1 = arel_infix_operation '@>',
46
+ Arel::Table.new('t')[tag_alias],
47
+ bind_for(tag.to_json, nil)
48
+ condition2 = arel_table[:id].in arel_sql(relation.reselect('id').to_sql)
49
+
50
+ arel_table
51
+ .project("id, #{tag_alias}, index")
52
+ .from("#{table_name}, #{arel_unnest(arel_column).to_sql} WITH ORDINALITY t(#{tag_alias}, index)")
53
+ .where(condition1)
54
+ .where(condition2)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -4,90 +4,134 @@ module PgTagsOn
4
4
  module Repositories
5
5
  # Operatons for 'jsonb[]' column type
6
6
  class ArrayJsonbRepository < ArrayRepository
7
- def create(tag, returning: nil)
8
- with_normalized_tags(tag) do |n_tag|
9
- super(n_tag, returning: returning)
10
- end
7
+ ##
8
+ # Tag's key in the JSON object.
9
+ # Can be a String or Array for nested attributes.
10
+ #
11
+ # @example
12
+ # { "name" => "lorem" } ; key = 'tag'
13
+ # { "tag" => { "name" => "lorem" } } ; key = ['tag', 'name']
14
+ #
15
+ def key
16
+ @key ||= options[:key]
11
17
  end
12
18
 
13
- def update(tag, new_tag, returning: nil)
14
- with_normalized_tags(tag, new_tag) do |n_tag, n_new_tag|
15
- sql_set = <<-SQL.strip
16
- #{column_name}[index] = #{column_name}[index] || $2
17
- SQL
18
-
19
- update_tag(n_tag,
20
- sql_set,
21
- bindings: [query_attribute(n_new_tag.to_json)],
22
- returning: returning)
23
- end
19
+ ##
20
+ # Returns true if JSON object has nested attributes.
21
+ #
22
+ def nested?
23
+ key.present?
24
24
  end
25
25
 
26
- def delete(tag, returning: nil)
27
- with_normalized_tags(tag) do |n_tag|
28
- sql_set = <<-SQL.strip
29
- #{column_name} = #{column_name}[1:index-1] || #{column_name}[index+1:2147483647]
30
- SQL
31
-
32
- update_tag(n_tag, sql_set, returning: returning)
33
- end
26
+ ##
27
+ # Add tag(s) to records.
28
+ #
29
+ # @param tag_or_tags [String|Array] Tags to be added.
30
+ # @param returning [String] Model's columns that'll be returned.
31
+ #
32
+ # @return [Array|Boolean] Returns boolean if :returning argument is nil
33
+ #
34
+ # @example
35
+ # Entity.tags.create "lorem", returning: "id,name"
36
+ # => [[1, 'row1'], [2, 'row2']]
37
+ #
38
+ def create(tag_or_tags, returning: nil)
39
+ tags = normalize_tags tag_or_tags
40
+
41
+ ArrayJsonb::Create.new.call repository: self,
42
+ relation: klass,
43
+ tags: tags,
44
+ returning: returning
34
45
  end
35
46
 
36
- private
37
-
38
- def with_normalized_tags(*tags, &block)
39
- normalized_tags = Array.wrap(tags).flatten.map do |tag|
40
- key? && Array.wrap(key).reverse.inject(tag) { |a, n| { n => a } } || tag
41
- end
42
-
43
- block.call(*normalized_tags)
47
+ ##
48
+ # Rename tag(s).
49
+ #
50
+ # @param tag [String] Tag that'll be renamed.
51
+ # @param new_tag [String] New tag name.
52
+ # @param returning [String] Model's columns that'll be returned.
53
+ #
54
+ # @return [Array|Boolean] Returns boolean if :returning argument is nil
55
+ #
56
+ # @example
57
+ # Entity.tags.update "lorem", "ipsum", returning: "id,name"
58
+ # => [[1, 'row1'], [2, 'row2']]
59
+ #
60
+ def update(tag, new_tag, returning: nil)
61
+ tag = normalize_one_tag tag
62
+ new_tag = normalize_one_tag new_tag
63
+
64
+ ArrayJsonb::Update.new.call repository: self,
65
+ relation: klass,
66
+ tag: tag,
67
+ new_tag: new_tag,
68
+ returning: returning
44
69
  end
45
70
 
46
- def array_to_recordset
47
- return unnest unless key?
48
-
49
- arel_jsonb_extract_path(unnest, *key_sql)
71
+ ##
72
+ # Delete tag(s).
73
+ #
74
+ # @param tag_or_tags [String] Tag that'll be renamed.
75
+ # @param returning [String] Model's columns that'll be returned.
76
+ #
77
+ # @return [Array|Boolean] Returns boolean if :returning argument is nil
78
+ #
79
+ # @example
80
+ # Entity.tags.delete "lorem", returning: "id,name"
81
+ # => [[1, 'row1'], [2, 'row2']]
82
+ #
83
+ def delete(tag_or_tags, returning: nil)
84
+ tags = normalize_tags tag_or_tags
85
+ relation = klass.where(column_name => PgTagsOn.query_class.any(tag_or_tags))
86
+
87
+ ArrayJsonb::Delete.new.call repository: self,
88
+ relation: relation,
89
+ tags: tags,
90
+ returning: returning
50
91
  end
51
92
 
52
- def key
53
- @key ||= options[:key]
54
- end
93
+ def normalize_tags(tags)
94
+ return normalize_one_tag(tags) unless tags.is_a?(Array)
55
95
 
56
- def key_sql
57
- @key_sql ||= Array.wrap(key).map { |k| Arel.sql("'#{k}'") }
96
+ tags.map { |t| normalize_one_tag(t) }
58
97
  end
59
98
 
60
- def key?
61
- key.present?
99
+ def normalize_one_tag(tag)
100
+ return tag unless nested?
101
+ return { key => tag } unless key.is_a?(Array)
102
+
103
+ key.reverse.inject(tag) { |a, k| { k => a } }
62
104
  end
63
105
 
64
- def taggings_with_ordinality_query(tag)
65
- column = Arel::Table.new('t')['name']
66
- value = bind_for(tag.to_json, nil)
106
+ ##
107
+ # @return [Arel::SelectManager]
108
+ #
109
+ def select_tags
110
+ return super unless nested?
67
111
 
68
- arel_table
69
- .project('id, name, index')
70
- .from("#{table_name}, #{unnest_with_ordinality}")
71
- .where(arel_infix_operation('@>', column, value))
112
+ klass
113
+ .select(arel_distinct(extract_tag_by_path).as(tag_alias))
114
+ .arel
72
115
  end
73
116
 
74
- def update_tag(tag, set_sql, bindings: [], returning: nil)
75
- subquery = taggings_with_ordinality_query(tag)
76
- .where(arel_table[:id].in(arel_sql(klass.reselect('id').to_sql)))
117
+ ##
118
+ # @return [Arel::SelectManager]
119
+ #
120
+ def select_taggings
121
+ return super unless nested?
77
122
 
78
- sql = <<-SQL.strip
79
- WITH records as ( #{subquery.to_sql} )
80
- UPDATE #{table_name}
81
- SET #{set_sql}
82
- FROM records
83
- WHERE #{table_name}.id = records.id
84
- SQL
85
- sql += " RETURNING #{Array.wrap(returning).join(', ')}" if returning.present?
123
+ klass
124
+ .select(extract_tag_by_path.as(tag_alias),
125
+ arel_table['id'].as('entity_id'))
126
+ .arel
127
+ end
86
128
 
87
- bindings = [query_attribute(tag.to_json)] + Array.wrap(bindings)
88
- result = klass.connection.exec_query(sql, 'SQL', bindings).rows
129
+ def tag_path_array
130
+ @tag_path_array ||= Array.wrap(key).map { |k| Arel.sql("'#{k}'") }
131
+ end
89
132
 
90
- returning ? result : true
133
+ def extract_tag_by_path
134
+ arel_jsonb_extract_path arel_unnest(arel_column), *tag_path_array
91
135
  end
92
136
  end
93
137
  end
@@ -4,131 +4,163 @@ module PgTagsOn
4
4
  module Repositories
5
5
  # This repository works with "character varying[]" and "integer[]" column types
6
6
  class ArrayRepository < BaseRepository
7
+ ##
8
+ # Select all tags ordered by name
9
+ #
10
+ # @return [Array[PgTagsOn::Tag]] Tags list.
11
+ #
12
+ # @example
13
+ # Entity.tags.all
14
+ #
7
15
  def all
8
- subquery = klass
9
- .select(arel_distinct(array_to_recordset).as('name'))
10
- .arel
11
- .as('tags')
12
-
13
16
  PgTagsOn::Tag
14
17
  .select(Arel.star)
15
- .from(subquery)
16
- .order('tags.name') # override rails' default order by id
17
- end
18
-
18
+ .from(select_tags.as('tags'))
19
+ .order(tag_alias)
20
+ end
21
+
22
+ ##
23
+ # Select all tags with counts
24
+ #
25
+ # @return [Array[PgTagsOn::Tag]] Tags list.
26
+ #
27
+ # @example
28
+ # Entity.tags.all_with_counts
29
+ #
19
30
  def all_with_counts
20
31
  taggings
21
- .except(:select)
22
- .select('name, count(name) as count')
23
- .group('name')
32
+ .reselect("#{tag_alias}, count(*) as count")
33
+ .group(tag_alias)
24
34
  end
25
35
 
36
+ ##
37
+ # Find one tag.
38
+ #
39
+ # @return [PgTagsOn::Tag]
40
+ #
26
41
  def find(tag)
27
42
  all.where(name: tag).first
28
43
  end
29
44
 
45
+ ##
46
+ # Returns true if tag exists.
47
+ #
48
+ # @return [Boolean]
49
+ #
30
50
  def exists?(tag)
31
- all.exists?(tag)
51
+ all.exists? tag
32
52
  end
33
53
 
54
+ ##
55
+ # Select taggings.
56
+ #
57
+ # @return [[PgTagsOn::Tag<entity_id, name>]]
58
+ #
34
59
  def taggings
35
60
  PgTagsOn::Tag
36
61
  .select(Arel.star)
37
- .from(taggings_query)
38
- .order('taggings.name')
62
+ .from(select_taggings.as('taggings'))
63
+ .order(tag_alias)
39
64
  end
40
65
 
66
+ ##
67
+ # Get tags count.
68
+ #
41
69
  def count
42
70
  all.count
43
71
  end
44
72
 
45
- def create(tag, returning: nil)
46
- raise 'Tag cannot be blank' if tag.blank?
47
-
48
- perform_update(klass,
49
- { column_name => arel_array_cat(arel_column, bind_for(Array.wrap(tag))) },
50
- returning: returning)
51
- end
52
-
73
+ ##
74
+ # Add tag(s) to records.
75
+ #
76
+ # @param tag_or_tags [String|Array] Tags to be added.
77
+ # @param returning [String] Model's columns that'll be returned.
78
+ #
79
+ # @return [Array|Boolean] Returns boolean if :returning argument is nil
80
+ #
81
+ # @example
82
+ # Entity.tags.create "lorem", returning: "id,name"
83
+ # => [[1, 'row1'], [2, 'row2']]
84
+ #
85
+ def create(tag_or_tags, returning: nil)
86
+ ArrayValue::Create.new.call repository: self,
87
+ relation: klass,
88
+ tags: tag_or_tags,
89
+ returning: returning
90
+ end
91
+
92
+ ##
93
+ # Rename tag(s).
94
+ #
95
+ # @param tag [String] Tag that'll be renamed.
96
+ # @param new_tag [String] New tag name.
97
+ # @param returning [String] Model's columns that'll be returned.
98
+ #
99
+ # @return [Array|Boolean] Returns boolean if :returning argument is nil
100
+ #
101
+ # @example
102
+ # Entity.tags.update "lorem", "ipsum", returning: "id,name"
103
+ # => [[1, 'row1'], [2, 'row2']]
104
+ #
53
105
  def update(tag, new_tag, returning: nil)
54
- raise 'Tag cannot be blank' if tag.blank? || new_tag.blank?
55
-
56
- rel = klass.where(column_name => PgTagsOn.query_class.one(tag))
57
-
58
- perform_update(rel,
59
- { column_name => arel_array_replace(arel_column, bind_for(tag), bind_for(new_tag)) },
60
- returning: returning)
61
- end
62
-
63
- def delete(tag, returning: nil)
64
- raise 'Tag cannot be blank' if tag.blank?
65
-
66
- rel = klass.where(column_name => PgTagsOn.query_class.one(tag))
67
-
68
- perform_update(rel, { column_name => arel_array_remove(arel_column, bind_for(tag)) }, returning: returning)
69
- end
70
-
71
- private
72
-
73
- def perform_update(rel, updates, returning: nil)
74
- updater = update_manager(rel, updates)
75
- sql, binds = klass.connection.send :to_sql_and_binds, updater
76
- sql += " RETURNING #{Array.wrap(returning).join(', ')}" if returning.present?
77
-
78
- result = klass.connection.exec_query(sql, 'SQL', binds).rows
79
-
80
- returning ? result : true
81
- end
82
-
83
- # Method copied from ActiveRecord as there is no way to inject sql into update manager.
84
- def update_manager(rel, updates)
85
- raise ArgumentError, 'Empty list of attributes to change' if updates.blank?
86
-
87
- stmt = ::Arel::UpdateManager.new
88
- stmt.table(arel_table)
89
- stmt.key = klass.arel_attribute(klass.primary_key)
90
- stmt.take(rel.arel.limit)
91
- stmt.offset(rel.arel.offset)
92
- stmt.order(*rel.arel.orders)
93
- stmt.wheres = rel.arel.constraints
94
- stmt.set rel.send(:_substitute_values, updates)
95
-
96
- stmt
97
- end
98
-
99
- def array_to_recordset
100
- unnest
101
- end
102
-
103
- def taggings_query
106
+ relation = klass.where(column_name => PgTagsOn.query_class.one(tag))
107
+
108
+ ArrayValue::Update.new.call repository: self,
109
+ relation: relation,
110
+ tag: tag,
111
+ new_tag: new_tag,
112
+ returning: returning
113
+ end
114
+
115
+ ##
116
+ # Delete tag(s).
117
+ #
118
+ # @param tag_or_tags [String] Tag that'll be renamed.
119
+ # @param returning [String] Model's columns that'll be returned.
120
+ #
121
+ # @return [Array|Boolean] Returns boolean if :returning argument is nil
122
+ #
123
+ # @example
124
+ # Entity.tags.delete "lorem", returning: "id,name"
125
+ # => [[1, 'row1'], [2, 'row2']]
126
+ #
127
+ def delete(tag_or_tags, returning: nil)
128
+ relation = klass.where(column_name => PgTagsOn.query_class.any(tag_or_tags))
129
+
130
+ ArrayValue::Delete.new.call repository: self,
131
+ relation: relation,
132
+ tags: tag_or_tags,
133
+ returning: returning
134
+ end
135
+
136
+ def tag_alias
137
+ 'name'
138
+ end
139
+
140
+ # protected
141
+
142
+ ##
143
+ # @return [Arel::SelectManager]
144
+ #
145
+ def select_tags
104
146
  klass
105
- .select(
106
- array_to_recordset.as('name'),
107
- arel_table['id'].as('entity_id')
108
- )
147
+ .select(arel_distinct(arel_unnest(arel_column)).as(tag_alias))
109
148
  .arel
110
- .as('taggings')
111
149
  end
112
150
 
113
- def ref
114
- "#{table_name}.#{column_name}"
115
- end
116
-
117
- def unnest
118
- arel_unnest(arel_column)
119
- end
120
-
121
- def unnest_with_ordinality(alias_table: 't')
122
- "#{unnest.to_sql} WITH ORDINALITY #{alias_table}(name, index)"
123
- end
124
-
125
- def query_attribute(value)
126
- arel_query_attribute(arel_column, value, cast_type)
151
+ ##
152
+ # @return [Arel::SelectManager]
153
+ #
154
+ def select_taggings
155
+ klass
156
+ .select(arel_unnest(arel_column).as(tag_alias),
157
+ arel_table['id'].as('entity_id'))
158
+ .arel
127
159
  end
128
160
 
129
161
  def bind_for(value, attr = arel_column)
130
- query_attr = arel_query_attribute(attr, value, cast_type)
131
- arel_bind(query_attr)
162
+ query_attr = arel_query_attribute attr, value, cast_type
163
+ arel_bind query_attr
132
164
  end
133
165
  end
134
166
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgTagsOn
4
+ module Repositories
5
+ module ArrayValue
6
+ # This class is respnsible to delete tags stored as JSON objects.
7
+ class Create
8
+ include ::PgTagsOn::ActiveRecord::Arel
9
+
10
+ delegate :column_name, :arel_column, :bind_for, to: :repository
11
+
12
+ def call(repository:, relation:, tags:, returning: nil)
13
+ @repository = repository
14
+ value = arel_array_cat arel_column, bind_for(Array.wrap(tags))
15
+
16
+ repository.update_attributes relation: relation,
17
+ attributes: { column_name => value },
18
+ returning: returning
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :repository, :tags
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgTagsOn
4
+ module Repositories
5
+ module ArrayValue
6
+ # This class is respnsible to delete tags stored as JSON objects.
7
+ class Delete
8
+ include ::PgTagsOn::ActiveRecord::Arel
9
+
10
+ delegate :column_name, :arel_column, :bind_for, :native_column_type, to: :repository
11
+
12
+ def call(repository:, relation:, tags:, returning: nil)
13
+ @repository = repository
14
+ @tags = tags
15
+
16
+ repository.update_attributes relation: relation,
17
+ attributes: { column_name => arel_function('array', active_tags) },
18
+ returning: returning
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :repository, :tags
24
+
25
+ ##
26
+ # For each record explode tags and filter out tags to be removed
27
+ #
28
+ def active_tags
29
+ Arel::SelectManager.new
30
+ .project(arel_unnest(arel_column))
31
+ .except(Arel::SelectManager.new
32
+ .project(arel_unnest(arel_cast(bind_for(Array.wrap(tags)), native_column_type))))
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgTagsOn
4
+ module Repositories
5
+ module ArrayValue
6
+ # This class is respnsible to delete tags stored as JSON objects.
7
+ class Update
8
+ include ::PgTagsOn::ActiveRecord::Arel
9
+
10
+ delegate :column_name, :arel_column, :bind_for, to: :repository
11
+
12
+ def call(repository:, relation:, tag:, new_tag:, returning: nil)
13
+ @repository = repository
14
+ value = arel_array_replace arel_column, bind_for(tag), bind_for(new_tag)
15
+
16
+ repository.update_attributes relation: relation,
17
+ attributes: { column_name => value },
18
+ returning: returning
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :repository, :tags
24
+ end
25
+ end
26
+ end
27
+ end
@@ -28,10 +28,6 @@ module PgTagsOn
28
28
  @table_name ||= klass.table_name
29
29
  end
30
30
 
31
- def cast_type
32
- @cast_type ||= klass.type_for_attribute(column_name)
33
- end
34
-
35
31
  def arel_table
36
32
  klass.arel_table
37
33
  end
@@ -39,6 +35,60 @@ module PgTagsOn
39
35
  def arel_column
40
36
  arel_table[column_name]
41
37
  end
38
+
39
+ ##
40
+ # ActiveRecord Column instance
41
+ #
42
+ def ar_column
43
+ @ar_column ||= klass.columns_hash[column_name.to_s]
44
+ end
45
+
46
+ ##
47
+ # Column's Type instance
48
+ #
49
+ # @return [ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array]
50
+ #
51
+ def cast_type
52
+ @cast_type ||= klass.type_for_attribute(column_name)
53
+ end
54
+
55
+ ##
56
+ # Database column type as string.
57
+ #
58
+ # @return [String] "character varying[]", "integer[]"
59
+ #
60
+ def native_column_type
61
+ type = ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES.fetch(cast_type.type)[:name]
62
+ type += '[]' if ar_column.array?
63
+
64
+ type
65
+ end
66
+
67
+ # Method copied from ActiveRecord as there is no way to inject sql into update manager.
68
+ def get_update_manager(relation:, updates:)
69
+ raise ArgumentError, 'Empty list of attributes to change' if updates.blank?
70
+
71
+ stmt = ::Arel::UpdateManager.new
72
+ stmt.table(arel_table)
73
+ stmt.key = arel_table[klass.primary_key]
74
+ stmt.take(relation.arel.limit)
75
+ stmt.offset(relation.arel.offset)
76
+ stmt.order(*relation.arel.orders)
77
+ stmt.wheres = relation.arel.constraints
78
+ stmt.set relation.send(:_substitute_values, updates)
79
+
80
+ stmt
81
+ end
82
+
83
+ def update_attributes(relation:, attributes:, returning: nil)
84
+ manager = get_update_manager relation: relation, updates: attributes
85
+ sql, binds = klass.connection.send :to_sql_and_binds, manager
86
+ sql += " RETURNING #{returning}" if returning.present?
87
+
88
+ result = klass.connection.exec_query(sql, 'SQL', binds).rows
89
+
90
+ returning ? result : true
91
+ end
42
92
  end
43
93
  end
44
94
  end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgTagsOn
4
+ ##
4
5
  # Repository class for tags.
6
+ #
5
7
  # Examples:
6
8
  #
7
9
  # repo = PgTagsOn::Repository.new(Entity, :tags)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgTagsOn
4
+ ##
4
5
  # Helper class to construct queries.
5
6
  # This class is registered in models' predicate builders.
6
7
  # See configuration in order to create an alias for it.
@@ -14,9 +15,7 @@ module PgTagsOn
14
15
  RUBY
15
16
  end
16
17
 
17
- attr_reader :value
18
- attr_reader :predicate
19
- attr_reader :options
18
+ attr_reader :value, :predicate, :options
20
19
 
21
20
  def initialize(value, predicate, options = {})
22
21
  @value = value
@@ -4,7 +4,7 @@ module PgTagsOn
4
4
  # Validator for max. number of tags and max. tag length.
5
5
  #
6
6
  # class Entity
7
- # pg_tags_on :tags, limit: 20, tag_length: 64
7
+ # pg_tags_on :tags, limit: 20, length: 64
8
8
  # end
9
9
  #
10
10
  class TagsValidator < ActiveModel::EachValidator
@@ -15,7 +15,7 @@ module PgTagsOn
15
15
 
16
16
  def validate_each(record, attribute, value)
17
17
  validate_limit(record, attribute, value)
18
- validate_tag_length(record, attribute, value)
18
+ validate_length(record, attribute, value)
19
19
 
20
20
  record.errors.present?
21
21
  end
@@ -31,8 +31,8 @@ module PgTagsOn
31
31
  record.errors.add(attr, "size exceeded #{limit} tags") if value.size > limit.to_i
32
32
  end
33
33
 
34
- def validate_tag_length(record, attr, value)
35
- limit, key = klass.pg_tags_on_options_for(attr).values_at(:tag_length, :key)
34
+ def validate_length(record, attr, value)
35
+ limit, key = klass.pg_tags_on_options_for(attr).values_at(:length, :key)
36
36
  return true unless limit && value
37
37
 
38
38
  value.map! { |tag| tag.with_indifferent_access.dig(*key) } if key
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgTagsOn
4
- VERSION = '0.1.4'
4
+ VERSION = '1.0.0'
5
5
  end
data/lib/pg_tags_on.rb CHANGED
@@ -18,7 +18,13 @@ require 'pg_tags_on/validations/validator'
18
18
  require 'pg_tags_on/repository'
19
19
  require 'pg_tags_on/repositories/base_repository'
20
20
  require 'pg_tags_on/repositories/array_repository'
21
+ require 'pg_tags_on/repositories/array_value/create'
22
+ require 'pg_tags_on/repositories/array_value/update'
23
+ require 'pg_tags_on/repositories/array_value/delete'
21
24
  require 'pg_tags_on/repositories/array_jsonb_repository'
25
+ require 'pg_tags_on/repositories/array_jsonb/create'
26
+ require 'pg_tags_on/repositories/array_jsonb/update'
27
+ require 'pg_tags_on/repositories/array_jsonb/delete'
22
28
  require 'pg_tags_on/benchmark/benchmark'
23
29
 
24
30
  # PgTagsOn configuration methods
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_tags_on
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Catalin Marinescu
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-04-12 00:00:00.000000000 Z
11
+ date: 2022-01-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -16,42 +16,42 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '6.0'
19
+ version: '7.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '6.0'
26
+ version: '7.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: activesupport
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '6.0'
33
+ version: '7.0'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '6.0'
40
+ version: '7.0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: pg
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - "~>"
45
+ - - '='
46
46
  - !ruby/object:Gem::Version
47
- version: '1.2'
47
+ version: 1.2.2
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - "~>"
52
+ - - '='
53
53
  - !ruby/object:Gem::Version
54
- version: '1.2'
54
+ version: 1.2.2
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: rake
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -106,8 +106,14 @@ files:
106
106
  - lib/pg_tags_on/predicate_handler/array_string_handler.rb
107
107
  - lib/pg_tags_on/predicate_handler/array_text_handler.rb
108
108
  - lib/pg_tags_on/predicate_handler/base_handler.rb
109
+ - lib/pg_tags_on/repositories/array_jsonb/create.rb
110
+ - lib/pg_tags_on/repositories/array_jsonb/delete.rb
111
+ - lib/pg_tags_on/repositories/array_jsonb/update.rb
109
112
  - lib/pg_tags_on/repositories/array_jsonb_repository.rb
110
113
  - lib/pg_tags_on/repositories/array_repository.rb
114
+ - lib/pg_tags_on/repositories/array_value/create.rb
115
+ - lib/pg_tags_on/repositories/array_value/delete.rb
116
+ - lib/pg_tags_on/repositories/array_value/update.rb
111
117
  - lib/pg_tags_on/repositories/base_repository.rb
112
118
  - lib/pg_tags_on/repository.rb
113
119
  - lib/pg_tags_on/tag.rb
@@ -122,7 +128,7 @@ metadata:
122
128
  changelog_uri: https://github.com/cata-m/pg_tags_on/blob/master/CHANGELOG.md
123
129
  homepage_uri: http://github.com/cata-m/pg_tags_on
124
130
  source_code_uri: https://github.com/cata-m/pg_tags_on
125
- post_install_message:
131
+ post_install_message:
126
132
  rdoc_options: []
127
133
  require_paths:
128
134
  - lib
@@ -130,15 +136,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
130
136
  requirements:
131
137
  - - ">="
132
138
  - !ruby/object:Gem::Version
133
- version: 2.3.0
139
+ version: 2.7.0
134
140
  required_rubygems_version: !ruby/object:Gem::Requirement
135
141
  requirements:
136
142
  - - ">="
137
143
  - !ruby/object:Gem::Version
138
144
  version: '0'
139
145
  requirements: []
140
- rubygems_version: 3.0.6
141
- signing_key:
146
+ rubygems_version: 3.1.6
147
+ signing_key:
142
148
  specification_version: 4
143
149
  summary: Query and manage tags stored in a Postgresql column.
144
150
  test_files: []