pw_query 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f954be61950ee22765fde8146ab319fc066209ded37810791701f94f01c4624
4
+ data.tar.gz: cf0007aa854ac28a03395a50438aa4a12b4af5066c990f4b3b9fe822c6cbc4cd
5
+ SHA512:
6
+ metadata.gz: 5ac11cbc9e45b5b023c83e5c73adb07f25f48b912761b04ed373b7722d0649b6a05d7719ebfa7758e5d420228e01bec1c58b27219594070d500aa56ba4ecdb50
7
+ data.tar.gz: '097d80461758f64234e7bd4fa3048ba252241f01b63a37014ef676ba21f8ef4836e9afbd8dcbad1cd90716eb9add91de338e0810e626cd778a76266bb36002b3'
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-12-07
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Jeffrey Dabo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # PwQuery
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/pw_query`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ $ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
14
+
15
+ If bundler is not being used to manage dependencies, install the gem by executing:
16
+
17
+ $ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Development
24
+
25
+ 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.
26
+
27
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
28
+
29
+ ## Contributing
30
+
31
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/pw_query. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/pw_query/blob/main/CODE_OF_CONDUCT.md).
32
+
33
+ ## License
34
+
35
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
36
+
37
+ ## Code of Conduct
38
+
39
+ Everyone interacting in the PwQuery project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/pw_query/blob/main/CODE_OF_CONDUCT.md).
@@ -0,0 +1,261 @@
1
+ require 'active_record'
2
+
3
+ module PwQuery
4
+ class Service
5
+ DEFAULT_LIMIT = 50
6
+
7
+ def initialize(model, params, options = {}, associations = nil)
8
+ @model = model
9
+ @params = params
10
+ @options = options
11
+ @associations = associations
12
+ @total = @model.count
13
+ @limit = @params.fetch(:limit, DEFAULT_LIMIT).to_i
14
+ @meta = {
15
+ total: @total,
16
+ limit: @limit,
17
+ page: @params.fetch(:page, 1).to_i,
18
+ page_size: @total,
19
+ total_pages: (@total.to_f / @limit).ceil
20
+ }
21
+ end
22
+
23
+ def perform
24
+ query = @model.all
25
+ query = include_associations(query, @associations)
26
+ query = apply_filters(query, @params[:filters])
27
+ query = apply_sorting(query, @params)
28
+ query = apply_field_selection(query, @params)
29
+ query = apply_date_range(query, @params[:date_range]) if @options[:date_scope]
30
+ query = apply_time_range(query, @params[:time_range])
31
+ query = apply_search(query, @params[:search])
32
+ query = apply_range(query, @params[:range])
33
+ query = apply_pagination(query, @params)
34
+ query = apply_aggregation(query, @params[:aggregation])
35
+
36
+ handle_query(query)
37
+
38
+ { meta: @meta, data: query }
39
+ end
40
+
41
+ def include_associations(query, associations)
42
+ query.includes(associations)
43
+ end
44
+
45
+ def apply_filters(query, filters)
46
+ return query if filters.blank?
47
+
48
+ filters.each do |key, value|
49
+ next if value.blank?
50
+
51
+ values = value.to_s.split(',').map(&:strip)
52
+ condition = values.size > 1 ? values : value
53
+ query = query.where(key => condition)
54
+ end
55
+
56
+ query
57
+ end
58
+
59
+ def apply_sorting(query, params)
60
+ if params[:sort_by]
61
+ apply_multi_sort(query, params[:sort_by])
62
+ else
63
+ apply_sort(query, params[:sort] || 'created_at:DESC')
64
+ end
65
+ end
66
+
67
+ def apply_sort(query, value)
68
+ sort_field, sort_order = value.split(':')
69
+ query.order("#{sort_field} #{sort_order}")
70
+ end
71
+
72
+ def apply_multi_sort(query, value)
73
+ sort_params = value.split(',')
74
+ sorting_criteria = sort_params.map { |param| param.split(':').join(' ') }
75
+ query.order(sorting_criteria.join(', '))
76
+ end
77
+
78
+ def apply_field_selection(query, params)
79
+ if params[:projection] || params[:fields]
80
+ fields = params[:projection] || params[:fields]
81
+ query.select(fields.split(','))
82
+ else
83
+ query
84
+ end
85
+ end
86
+
87
+ def apply_date_range(query, value)
88
+ start_date = value ? parse_date(value['start']) : 1.month.ago.beginning_of_day
89
+ end_date = value ? parse_date(value['end']) : Time.current
90
+ query.where(created_at: start_date...end_date)
91
+ end
92
+
93
+ def apply_time_range(query, value)
94
+ return query if value.blank?
95
+
96
+ start_time = parse_time(value['start'] || '00:00:00')
97
+ end_time = parse_time(value['end'] || '23:59:59')
98
+
99
+ table_name = query.model.table_name
100
+ if start_time <= end_time
101
+ query.where("CAST(#{table_name}.created_at AS time) BETWEEN ? AND ?", start_time, end_time)
102
+ else
103
+ query.where("CAST(#{table_name}.created_at AS time) >= ? OR CAST(#{table_name}.created_at AS time) <= ?",
104
+ start_time, end_time)
105
+ end
106
+ end
107
+
108
+ def apply_search(query, filters)
109
+ return query if filters.blank?
110
+
111
+ filters = filters.to_unsafe_h
112
+ conditions = build_filter_conditions(query, filters)
113
+ apply_conditions(query, conditions)
114
+ end
115
+
116
+ def build_filter_conditions(query, filters)
117
+ filters.map do |key, value|
118
+ next if value.blank?
119
+
120
+ key.include?('.') ? association_condition(query, key, value) : direct_condition(query, key, value)
121
+ end.compact
122
+ end
123
+
124
+ def association_condition(query, key, value)
125
+ association_chain = key.split('.')
126
+ attribute = association_chain.pop
127
+ current_table = build_join_chain(query, association_chain)
128
+ create_condition(current_table, attribute, value)
129
+ end
130
+
131
+ def build_join_chain(query, chain)
132
+ current_model = query.klass
133
+ current_table = current_model.table_name
134
+ join_chain = []
135
+
136
+ chain.each do |assoc|
137
+ reflection = current_model.reflect_on_association(assoc.to_sym) or raise "Unknown association: #{assoc} for model #{current_model}"
138
+ table_alias = "#{reflection.klass.table_name}_as_#{current_table}"
139
+ join_chain << { table: reflection.klass.table_name, alias: table_alias, foreign_key: reflection.foreign_key,
140
+ parent_table: current_table }
141
+ current_table = table_alias
142
+ current_model = reflection.klass
143
+ end
144
+
145
+ query.joins(join_chain.map do |join|
146
+ "LEFT JOIN #{join[:table]} AS #{join[:alias]} ON #{join[:alias]}.id = #{join[:parent_table]}.#{join[:foreign_key]}"
147
+ end.join(' '))
148
+ end
149
+
150
+ def direct_condition(query, key, value)
151
+ create_condition(query.klass.table_name, key, value)
152
+ end
153
+
154
+ def create_condition(table, attribute, value)
155
+ values = value.to_s.split(',').map(&:strip)
156
+ column = get_column_type(table, attribute)
157
+
158
+ case column&.type
159
+ when :string, :text
160
+ condition = if values.size > 1
161
+ values.map do |_v|
162
+ "LOWER(#{table}.#{attribute}::text) LIKE LOWER(?)"
163
+ end.join(' OR ')
164
+ else
165
+ "LOWER(#{table}.#{attribute}::text) LIKE LOWER(?)"
166
+ end
167
+ values = values.size > 1 ? values.map { |v| "%#{escape_like(v)}%" } : ["%#{escape_like(value)}%"]
168
+ [condition, *values]
169
+ else
170
+ values.size > 1 ? ["#{table}.#{attribute} IN (?)", values] : ["#{table}.#{attribute} = ?", value]
171
+ end
172
+ end
173
+
174
+ def apply_conditions(query, conditions)
175
+ return query unless conditions.any?
176
+
177
+ where_clause = conditions.map(&:first).join(' OR ')
178
+ where_values = conditions.map(&:last).flatten
179
+ query.where(where_clause, *where_values)
180
+ end
181
+
182
+ def apply_range(query, ranges)
183
+ return query if ranges.blank?
184
+
185
+ ranges.each do |key, value|
186
+ range_values = value.split(',')
187
+ min_value = range_values[0].to_f if range_values[0].present?
188
+ max_value = range_values[1].to_f if range_values[1].present?
189
+
190
+ if min_value.present? && max_value.present?
191
+ query = query.where("#{key} BETWEEN ? AND ?", min_value, max_value)
192
+ elsif min_value.present?
193
+ query = query.where("#{key} >= ?", min_value)
194
+ elsif max_value.present?
195
+ query = query.where("#{key} <= ?", max_value)
196
+ end
197
+ end
198
+
199
+ query
200
+ end
201
+
202
+ def apply_pagination(query, params)
203
+ page = params.fetch(:page, 1).to_i
204
+ offset_value = page == 1 ? 0 : (page - 1) * @limit
205
+ total_pages = (@total.to_f / @limit).ceil
206
+
207
+ page <= total_pages ? query.offset(offset_value).limit(@limit) : query.none
208
+ end
209
+
210
+ def apply_aggregation(query, value)
211
+ return query if value.nil? || value.empty?
212
+
213
+ aggregation_type, aggregation_field = value.split(':')
214
+
215
+ case aggregation_type
216
+ when 'count'
217
+ query.count
218
+ when 'sum'
219
+ query.sum(aggregation_field)
220
+ when 'average'
221
+ query.average(aggregation_field)
222
+ when 'max'
223
+ query.maximum(aggregation_field)
224
+ when 'min'
225
+ query.minimum(aggregation_field)
226
+ else
227
+ query
228
+ end
229
+ end
230
+
231
+ def get_column_type(table, attribute)
232
+ klass = table.classify.constantize
233
+ klass.columns_hash[attribute.to_s]
234
+ rescue StandardError
235
+ nil
236
+ end
237
+
238
+ def escape_like(string)
239
+ string.gsub(/[\\%_]/) { |m| "\\#{m}" }
240
+ end
241
+
242
+ def parse_time(time_string)
243
+ Time.parse(time_string).strftime('%H:%M:%S')
244
+ rescue ArgumentError
245
+ '00:00:00'
246
+ end
247
+
248
+ def parse_date(date_string)
249
+ Date.parse(date_string) if date_string.present?
250
+ rescue ArgumentError
251
+ nil
252
+ end
253
+
254
+ private
255
+
256
+ def handle_query(query)
257
+ @meta['page_size'] = query.count
258
+ query
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PwQuery
4
+ VERSION = "1.0.0"
5
+ end
data/lib/pw_query.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pw_query/version'
4
+ require_relative 'pw_query/service'
5
+
6
+ module PwQuery
7
+ class Error < StandardError; end
8
+
9
+ def self.execute(model, params, options = {}, associations = nil)
10
+ Service.new(model, params, options, associations).perform
11
+ end
12
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pw_query
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeffrey Dabo
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-12-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: httparty
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.18'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.18'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.0'
41
+ description: This gem provides an advanced querying service for interacting with Purple
42
+ Wave's Rails-backed APIs, enabling efficient data retrieval and manipulation.
43
+ email:
44
+ - jeffrey.dabo@purplewave.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - lib/pw_query.rb
53
+ - lib/pw_query/service.rb
54
+ - lib/pw_query/version.rb
55
+ homepage: https://github.com/purplewave/pw_query
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ allowed_push_host: https://rubygems.org
60
+ homepage_uri: https://github.com/purplewave/pw_query
61
+ source_code_uri: https://github.com/purplewave/pw_query
62
+ changelog_uri: https://github.com/purplewave/pw_query/blob/main/CHANGELOG.md
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 3.0.0
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.5.11
79
+ signing_key:
80
+ specification_version: 4
81
+ summary: Advanced querying service for Purple Wave Rails-backed APIs
82
+ test_files: []