magiq 0.1.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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ec60d6eebae1b801609b5f02fd507a010c4a40b4
4
+ data.tar.gz: 7b9662e27f4dde73ce0dac5e313e9dd5a8dd7913
5
+ SHA512:
6
+ metadata.gz: 5c176e692ffa6d2ca00d1652c7861eedb86ab696d1cb5f6e439eb278e4fbc2a4fe980914d636e84332fafd53838cdf75733df16c5071bbb626f645112f7a0f16
7
+ data.tar.gz: 4053b9d9ed3b8f0a0ac099592859f3349c36235e949fe9daf27e7a475bd785bb766ef73541faef7d57f30971f5290ff179dd878b33962798c2b405a8ec1ea58d
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.3
5
+ before_install: gem install bundler -v 1.14.6
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at heycarsten@gmail.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in magiq.gemspec
4
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Carsten Nielsen
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.
@@ -0,0 +1,65 @@
1
+ # Magiq
2
+
3
+ ### A library for building magical query interfaces for Arel scopes
4
+
5
+ _Say it like: "Ma-Jee-Que"_
6
+
7
+ This is a small ad-hoc utility library that I built in 2014 for use in
8
+ [LCBO API](https://lcboapi.com). I wanted a clean, declarative way to specify
9
+ query param interfaces to my API endpoints.
10
+
11
+ Ultimately, it works! But life happened and I never really got the second
12
+ version out the door. Since then JSON-API has really matured and a number of
13
+ libraries have been released to solve this problem and much more.
14
+
15
+ These days I'd like to just use `JSONAPI::Resources` for everything, but I still
16
+ sort of find myself wanting for a better way to specifiy query interfaces.
17
+
18
+ I'd like to morph `Magiq` into something that could enhance sorting and
19
+ filtering in `JSONAPI::Resources`. It might remain a more generic library with
20
+ an integration gem `jsonapi-resources-magiq` maybe?
21
+
22
+ ## Installation
23
+
24
+ You'll probably be using this in a Rails application or something similar,
25
+ simply add this line to your application's Gemfile:
26
+
27
+ ```ruby
28
+ gem 'magiq'
29
+ ```
30
+
31
+ And then execute:
32
+
33
+ ```
34
+ $ bundle
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```
40
+ app/queries/
41
+ TODO: Write usage instructions here
42
+
43
+ ## Development
44
+
45
+ After checking out the repo, run `bin/setup` to install dependencies. Then,
46
+ run `rake spec` to run the tests. You can also run `bin/console` for an
47
+ interactive prompt that will allow you to experiment.
48
+
49
+ To install this gem onto your local machine, run `bundle exec rake install`.
50
+ To release a new version, update the version number in `version.rb`, and then
51
+ run `bundle exec rake release`, which will create a git tag for the version,
52
+ push git commits and tags, and push the `.gem` file to
53
+ [rubygems.org](https://rubygems.org).
54
+
55
+ ## Contributing
56
+
57
+ Bug reports and pull requests are welcome on GitHub at
58
+ https://github.com/heycarsten/magiq. This project is intended to be a safe,
59
+ welcoming space for collaboration, and contributors are expected to adhere to
60
+ the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
61
+
62
+ ## License
63
+
64
+ The gem is available as open source under the terms of the
65
+ [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "magiq"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,31 @@
1
+ require 'ostruct'
2
+ require 'magiq/version'
3
+
4
+ module Magiq
5
+ class Error < StandardError; end
6
+ class ParamsError < Error; end
7
+ class BadParamError < Error; end
8
+
9
+ DEFAULT_CONFIG = OpenStruct.new(
10
+ array_param_limit: 150,
11
+ default_page_size: 50,
12
+ max_page_size: 250,
13
+ min_page_size: 1
14
+ )
15
+
16
+ module_function
17
+
18
+ def [](key)
19
+ config[key]
20
+ end
21
+
22
+ def config
23
+ @config ||= DEFAULT_CONFIG.dup
24
+ end
25
+
26
+ def configure
27
+ yield(config)
28
+ end
29
+ end
30
+
31
+ require 'magiq/query'
@@ -0,0 +1,60 @@
1
+ require 'magiq/param'
2
+
3
+ module Magiq
4
+ class Builder
5
+ END_RNG = /\}([a-z0-9_]+)\Z/
6
+ START_RNG = /\A([a-z0-9_]+)\{/
7
+ CONSTRAINTS = [:mutual, :exclusive]
8
+ LISTENERS = [:check, :apply]
9
+
10
+ attr_reader :listeners, :constraints, :params
11
+
12
+ def initialize
13
+ @listeners = Hash.new { |h, k| h[k] = [] }
14
+ @constraints = []
15
+ @params = {}
16
+ end
17
+
18
+ def add_listener(type, params, opts = {}, &block)
19
+ listeners_for(type) << [type, params, opts, block]
20
+ end
21
+
22
+ def listeners_for(type)
23
+ if !LISTENERS.include?(type)
24
+ raise ArgumentError, "unknown listener type: #{type.inspect}"
25
+ end
26
+
27
+ listeners[type]
28
+ end
29
+
30
+ def add_param(key, opts = {})
31
+ param = Param.new(key, opts)
32
+
33
+ param.keys.each do |k|
34
+ if params.key?(k.to_sym)
35
+ raise ArgumentError, "already registered param under key/alias: " \
36
+ "#{k}"
37
+ end
38
+
39
+ params[k] = param
40
+ end
41
+ end
42
+
43
+ def add_constraint(op, params_arg, opts = {})
44
+ if !CONSTRAINTS.include?(op)
45
+ raise ArgumentError, "unknown constraint type: #{op.inspect}"
46
+ end
47
+
48
+ params = Array(params_arg)
49
+
50
+ if params.size == 1 && !opts[:exclusive]
51
+ raise ArgumentError, "a single parameter can't be mutual unless it " \
52
+ "has an exclusive counterpart"
53
+ end
54
+
55
+ params.each do |p|
56
+ constraints << [op, params, opts]
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,87 @@
1
+ require 'magiq/types'
2
+
3
+ module Magiq
4
+ class Param
5
+ attr_reader :key, :type, :keys, :aliases
6
+
7
+ OPTS = [
8
+ TYPE = :type,
9
+ SOLO = :solo,
10
+ LIMIT = :limit,
11
+ ALIAS = :alias,
12
+ ARRAY = :array
13
+ ]
14
+
15
+ def initialize(key, opts = {})
16
+ @key = key.to_sym
17
+ @type = Types.lookup(opts[TYPE] || :string)
18
+ @solo = opts[SOLO] ? true : false
19
+ @limit = opts[LIMIT] || Magiq[:array_param_limit]
20
+ @aliases = opts[ALIAS] ? Array(opts[:alias]) : []
21
+ @keys = [@key].concat(@aliases).map(&:to_sym)
22
+
23
+ @array = case opts[ARRAY]
24
+ when :always
25
+ :always
26
+ when :allow
27
+ :allow
28
+ when nil, false
29
+ false
30
+ else
31
+ raise ArgumentError, ":array option must be :always, :allow, or false, " \
32
+ "not: #{opts[ARRAY].inspect}"
33
+ end
34
+ end
35
+
36
+ def clean(raw_value)
37
+ v = raw_value.to_s.strip
38
+ v == '' ? nil : v
39
+ end
40
+
41
+ def accepts_array?
42
+ @array ? true : false
43
+ end
44
+
45
+ def solo?
46
+ @solo
47
+ end
48
+
49
+ def extract(raw_value)
50
+ return unless raw_value
51
+
52
+ if raw_value.is_a?(Array) && !accepts_array?
53
+ raise BadParamError, "An array of values was passed to the `#{key}` " \
54
+ "parameter but it is not permitted to accept more than one value."
55
+ end
56
+
57
+ value = case @array
58
+ when :always
59
+ raw_value.is_a?(Array) ? raw_value : raw_value.split(',')
60
+ when :allow
61
+ if raw_value.is_a?(Array)
62
+ raw_value
63
+ elsif raw_value.include?(',')
64
+ raw_value.split(',')
65
+ else
66
+ raw_value
67
+ end
68
+ else
69
+ raw_value
70
+ end
71
+
72
+ if value.is_a?(Array) && @limit && value.size > @limit
73
+ raise BadParamError, "The number of items passed to the `#{key}` " \
74
+ "parameter is #{value.size} which exceeds the permitted maxium of " \
75
+ "#{@limit} items."
76
+ end
77
+
78
+ if value.is_a?(Array)
79
+ return value.map { |v| @type.cast(clean(v)) }
80
+ end
81
+
82
+ return unless (v = clean(value))
83
+
84
+ @type.cast(v)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,408 @@
1
+ require 'magiq/builder'
2
+ require 'magiq/utils'
3
+
4
+ module Magiq
5
+ class Query
6
+ attr_reader :raw_params, :params, :scope, :model, :solo_param
7
+
8
+ def self.builder
9
+ @builder ||= Builder.new
10
+ end
11
+
12
+ def self.model(&block)
13
+ @model_proc = block
14
+ end
15
+
16
+ def self.model_proc
17
+ @model_proc
18
+ end
19
+
20
+ def self.scope(&block)
21
+ @scope_proc = block
22
+ end
23
+
24
+ def self.scope_proc
25
+ @scope_proc || -> { model.unscoped }
26
+ end
27
+
28
+ def self.unqualified_columns
29
+ @unqualified_columns ||= []
30
+ end
31
+
32
+ def self.unqualified(fields)
33
+ unqualified_columns.concat(fields)
34
+ end
35
+
36
+ def self.params(*keys, &block)
37
+ opts = keys.last.is_a?(Hash) ? keys.pop : {}
38
+
39
+ if block_given?
40
+ types = opts.delete(:type)
41
+
42
+ keys.each do |k|
43
+ type = types.is_a?(Hash) ? types[k] : types
44
+ def_param(k, opts.merge(type: type))
45
+ end
46
+
47
+ apply(*keys, &block)
48
+ else
49
+ def_param(keys[0], opts)
50
+ end
51
+ end
52
+
53
+ def self.param(*args, &block)
54
+ params(*args, &block)
55
+ end
56
+
57
+ def self.def_param(key, opts = {})
58
+ builder.add_param(key, opts)
59
+ end
60
+
61
+ def self.apply(*params, &block)
62
+ opts = params.last.is_a?(Hash) ? params.pop : {}
63
+ builder.add_listener(:apply, params, opts, &block)
64
+ end
65
+
66
+ def self.check(*params, &block)
67
+ opts = params.last.is_a?(Hash) ? params.pop : {}
68
+ builder.add_listener(:check, params, opts, &block)
69
+ end
70
+
71
+ def self.mutual(params, opts = {})
72
+ builder.add_constraint(:mutual, params, opts)
73
+ end
74
+
75
+ def self.exclusive(*params)
76
+ builder.add_constraint(:exclusive, params)
77
+ end
78
+
79
+ def self.has_pagination(opts = {})
80
+ max_page_size = opts[:max_page_size] || Magiq[:max_page_size]
81
+ min_page_size = opts[:min_page_size] || Magiq[:min_page_size]
82
+ default_page_size = opts[:default_page_size] || Magiq[:default_page_size]
83
+
84
+ param :page, type: :whole
85
+ param :page_size, type: :whole
86
+
87
+ check :page, :page_size, any: true do |page, page_size|
88
+ if page && page < 1
89
+ bad! "The value provided for `page` must be 1 or greater, but " \
90
+ "#{page.inspect} was provided."
91
+ end
92
+
93
+ if page_size && page_size > max_page_size
94
+ bad! "The maximum permitted value for `page_size` is " \
95
+ "#{max_page_size}, but #{page_size.inspect} was provided."
96
+ elsif page_size && page_size < min_page_size
97
+ bad! "The minimum permitted value for `page_size` is " \
98
+ "#{min_page_size}, but #{page_size.inspect} was provided."
99
+ end
100
+ end
101
+
102
+ apply do
103
+ next if solo?
104
+
105
+ page = params[:page]
106
+ page_size = params[:page_size] || default_page_size
107
+ new_scope = scope.page(page)
108
+
109
+ page_size ? new_scope.per(page_size) : new_scope
110
+ end
111
+ end
112
+
113
+ def self.toggle(*fields)
114
+ fields.each do |field|
115
+ param(field, type: :bool)
116
+ apply(field) do |val|
117
+ scope.where(field => val)
118
+ end
119
+ end
120
+ end
121
+
122
+ def self.sort(fields)
123
+ param :sort, type: :string, array: :always, limit: fields.size
124
+ apply :sort do |raw_vals|
125
+ vals = raw_vals.is_a?(Array) ? raw_vals : raw_vals.split(',')
126
+
127
+ vals.reduce(scope) do |scope, val|
128
+ col = if val.start_with?('-')
129
+ direction = :desc
130
+ val.sub('-', '').to_sym
131
+ elsif val.start_with?('+')
132
+ direction = :asc
133
+ val.sub('+', '').to_sym
134
+ else
135
+ bad! "A sort order was not specified for the sort field value " \
136
+ "'#{val}', it must be prefixed with either a plus (+#{val}) for " \
137
+ "ascending order, or a minus (-#{val}) for decending order"
138
+ end
139
+
140
+ unless fields.include?(col)
141
+ bad! "A provided sorting field: #{col}, is unknown or unsortable. The " \
142
+ "permitted values are: #{fields.join(', ')}"
143
+ end
144
+
145
+ if is_unqualified?(col)
146
+ scope.order("\"#{col}\" #{direction}")
147
+ else
148
+ scope.order(col => direction)
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ def self.by(column, opts = {}, &block)
155
+ param(column, {
156
+ solo: true,
157
+ type: :id,
158
+ alias: Magiq::Utils.pluralize(column.to_s).to_sym,
159
+ array: :always
160
+ }.merge(opts))
161
+
162
+ if block_given?
163
+ apply(column, &block)
164
+ else
165
+ apply(column) do |ids|
166
+ if ids.empty?
167
+ nil
168
+ else
169
+ tbl = model.table_name
170
+
171
+ sql = ids.each_with_index.map { |raw_id, i|
172
+ id = raw_id.is_a?(Numeric) ? raw_id : "'#{raw_id}'"
173
+ "WHEN #{id} THEN #{i}"
174
+ }.join(' ')
175
+
176
+ scope.where(column => ids).order("CASE #{tbl}.#{column} #{sql} END")
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ def self.range(field, opts = {})
183
+ lt_param = :"#{field}_lt"
184
+ lte_param = :"#{field}_lte"
185
+ gt_param = :"#{field}_gt"
186
+ gte_param = :"#{field}_gte"
187
+ eq_param = field.to_sym
188
+
189
+ param(lt_param, type: opts[:type] || :whole)
190
+ param(lte_param, type: opts[:type] || :whole)
191
+ param(gt_param, type: opts[:type] || :whole)
192
+ param(gte_param, type: opts[:type] || :whole)
193
+ param(eq_param, type: opts[:type] || :whole)
194
+
195
+ exclusive(gt_param, gte_param)
196
+ exclusive(lt_param, lte_param)
197
+ mutual(eq_param, exclusive: [lt_param, lte_param, gt_param, gte_param])
198
+
199
+ check do
200
+ next if Magiq::Utils.present?(params[eq_param])
201
+
202
+ lt_par = if (lt_val = params[lte_param])
203
+ lte_param
204
+ elsif (lt_val = params[lt_param])
205
+ lt_param
206
+ end
207
+
208
+ gt_par = if (gt_val = params[gte_param])
209
+ gte_param
210
+ elsif (gt_val = params[gt_param])
211
+ gt_param
212
+ end
213
+
214
+ next unless lt_par && gt_par
215
+
216
+ if lt_val > gt_val
217
+ bad! "A value of #{lt_val} was provided for `#{lt_par}` but a value " \
218
+ "of #{gt_val} was provided for `#{gt_par}`. The permitted value of " \
219
+ "`#{lt_par}` must be less than the permitted value provided for " \
220
+ "`#{gt_par}`."
221
+ end
222
+
223
+ if lt_val == gt_val
224
+ bad! "The same value of #{gt_val} was provided for both `#{lt_par}` " \
225
+ "and #{gt_par}. The permitted value of `#{lt_par}` must be " \
226
+ "less than the permitted value provided for `#{gt_par}`."
227
+ end
228
+ end
229
+
230
+ apply(eq_param) do |val|
231
+ if is_unqualified?(field)
232
+ scope.where("#{field} = ?", val)
233
+ else
234
+ scope.where(model.arel_table[field].eq(val))
235
+ end
236
+ end
237
+
238
+ apply(gt_param) do |val|
239
+ if is_unqualified?(field)
240
+ scope.where("#{field} > ?", val)
241
+ else
242
+ scope.where(model.arel_table[field].gt(val))
243
+ end
244
+ end
245
+
246
+ apply(lt_param) do |val|
247
+ if is_unqualified?(field)
248
+ scope.where("#{field} < ?", val)
249
+ else
250
+ scope.where(model.arel_table[field].lt(val))
251
+ end
252
+ end
253
+
254
+ apply(gte_param) do |val|
255
+ if is_unqualified?(field)
256
+ scope.where("#{field} >= ?", val)
257
+ else
258
+ scope.where(model.arel_table[field].gte(val))
259
+ end
260
+ end
261
+
262
+ apply(lte_param) do |val|
263
+ if is_unqualified?
264
+ scope.where("#{field} <= ?", val)
265
+ else
266
+ scope.where(model.arel_table[field].lte(val))
267
+ end
268
+ end
269
+ end
270
+
271
+ def initialize(params)
272
+ @raw_params = params
273
+ @listeners = {}
274
+ end
275
+
276
+ def builder
277
+ self.class.builder
278
+ end
279
+
280
+ def update_scope!(new_scope)
281
+ return unless new_scope
282
+ @scope = new_scope
283
+ end
284
+
285
+ def is_unqualified?(column)
286
+ self.class.unqualified_columns.include?(column)
287
+ end
288
+
289
+ def extract!
290
+ @params = {}
291
+
292
+ raw_params.each_pair do |raw_key, raw_value|
293
+ key = raw_key.to_sym
294
+
295
+ next unless (param = builder.params[key])
296
+
297
+ begin
298
+ next unless (value = param.extract(raw_value))
299
+ @params[param.key] = value
300
+ rescue BadParamError => e
301
+ raise BadParamError, "The `#{param.key}` parameter is invalid: " \
302
+ "#{e.message}"
303
+ end
304
+ end
305
+
306
+ @params.keys.each do |p|
307
+ next unless (found = builder.params[p])
308
+ next unless found.solo?
309
+
310
+ if @params.size > 1
311
+ raise BadParamError, "The `#{found.key}` parameter can only be used " \
312
+ "by itself in a query."
313
+ else
314
+ @has_solo_param = true
315
+ @solo_param = found
316
+ end
317
+ end
318
+ end
319
+
320
+ def verify!
321
+ if !@params
322
+ raise RuntimeError, "verify! was called before extract!"
323
+ end
324
+
325
+ builder.constraints.each do |(op, keys, opts)|
326
+ case op
327
+ when :exclusive
328
+ found_keys = keys.select { |k| params.key?(k) }
329
+
330
+ next if found_keys.empty? || found_keys.one?
331
+
332
+ raise ParamsError, "The following parameters are not permitted " \
333
+ "to be provided together: #{found_keys.join(', ')}"
334
+ when :mutual
335
+ exclusives = opts[:exclusive] && Array(opts[:exclusive]) || []
336
+ found_keys = keys.select { |k| params.key?(k) }
337
+ found_excl = exclusives.select { |k| params.key?(k) }
338
+
339
+ next if found_keys.empty?
340
+ next if found_keys.empty? && found_excl.empty?
341
+ next if found_keys == keys && found_excl.empty?
342
+
343
+ if found_excl.any?
344
+ raise ParamsError, "The provided " \
345
+ "parameter#{found_keys.one? ? '' : 's'}: " \
346
+ "#{found_keys.map { |k| "`#{k}`" }.join(', ')} " \
347
+ "#{found_keys.one? ? 'is' : 'are'} mutually exclusive to: " \
348
+ "#{found_excl.map { |k| "`#{k}`" }.join ', '}."
349
+ end
350
+
351
+ raise ParamsError, "The provided " \
352
+ "parameter#{found_keys.one? ? '' : 's'}: " \
353
+ "#{found_keys.map { |k| "`#{k}`" }.join(', ')} requires: " \
354
+ "#{(keys - found_keys).map { |k| "`#{k}`" }.join(', ')}."
355
+ end
356
+ end
357
+ end
358
+
359
+ def check!
360
+ @model = instance_exec(&self.class.model_proc)
361
+ @scope = instance_exec(&self.class.scope_proc)
362
+
363
+ each_listener_for :check do |seek_params, opts, op|
364
+ next instance_exec(&op) if seek_params.empty?
365
+ next if !opts[:any] && !seek_params.all? { |p| params.key?(p) }
366
+
367
+ vals = seek_params.map { |p| params[p] }
368
+ instance_exec(*vals, &op)
369
+ end
370
+ end
371
+
372
+ def apply!
373
+ each_listener_for :apply do |seek_params, opts, op|
374
+ next update_scope! instance_exec(&op) if seek_params.empty?
375
+ next if !opts[:any] && !seek_params.all? { |p| params.key?(p) }
376
+
377
+ vals = seek_params.map { |p| params[p] }
378
+ update_scope! instance_exec(*vals, &op)
379
+ end
380
+ end
381
+
382
+ def solo?
383
+ @has_solo_param ? true : false
384
+ end
385
+
386
+ def bad!(message)
387
+ raise BadParamError, message
388
+ end
389
+
390
+ def listeners_for(type)
391
+ builder.listeners_for(type)
392
+ end
393
+
394
+ def each_listener_for(type, &block)
395
+ listeners_for(type).each do |t, params, opts, op|
396
+ block.(params, opts, op)
397
+ end
398
+ end
399
+
400
+ def to_scope
401
+ extract!
402
+ verify!
403
+ check!
404
+ apply!
405
+ @scope
406
+ end
407
+ end
408
+ end
@@ -0,0 +1,216 @@
1
+ require 'date'
2
+
3
+ module Magiq
4
+ module Types
5
+ module_function
6
+
7
+ def registry
8
+ @registry ||= {}
9
+ end
10
+
11
+ def register(id, adapter)
12
+ registry[id.to_sym] = adapter
13
+ end
14
+
15
+ def lookup(id)
16
+ if (found = registry[id.to_sym])
17
+ found
18
+ else
19
+ raise ArgumentError, "no type is registered under: #{id.inspect}"
20
+ end
21
+ end
22
+
23
+ class Type
24
+ attr_reader :raw
25
+
26
+ def self.cast(raw)
27
+ new(raw).cast!
28
+ end
29
+
30
+ def initialize(raw)
31
+ @raw = raw.to_s.strip
32
+ end
33
+
34
+ def cast!
35
+ raw
36
+ end
37
+
38
+ protected
39
+
40
+ def bad!(message)
41
+ raise BadParamError, message
42
+ end
43
+ end
44
+
45
+ class String < Type
46
+ def cast!
47
+ raw == nil ? nil : raw.to_s
48
+ end
49
+ end
50
+ register :string, String
51
+
52
+ class Bool < Type
53
+ def cast!
54
+ case v = raw.downcase
55
+ when 't', 'true', '1', 'yes', 'y'
56
+ true
57
+ when 'f', 'false', '0', 'no', 'n'
58
+ false
59
+ else
60
+ bad! "provided value of #{raw.inspect} is not permitted, the " \
61
+ "permitted values are: \"true\", or \"false\""
62
+ end
63
+ end
64
+ end
65
+ register :bool, Bool
66
+
67
+ class Float < Type
68
+ def cast!
69
+ raw.to_f
70
+ end
71
+ end
72
+ register :float, Float
73
+
74
+ class Int < Type
75
+ def cast!
76
+ raw.to_i
77
+ end
78
+ end
79
+ register :int, Int
80
+
81
+ class ID < Type
82
+ def cast!
83
+ v = raw.to_i
84
+
85
+ if v > 0
86
+ v
87
+ else
88
+ bad! "provided value of #{raw.inspect} is not permitted, it must " \
89
+ "be a numerical ID greater than zero."
90
+ end
91
+ end
92
+ end
93
+ register :id, ID
94
+
95
+ class UPC < Type
96
+ UPC_RNG = /[^0-9]/
97
+ UPC_MAX = 9999999999999
98
+
99
+ def cast!
100
+ v = raw.to_s.gsub(UPC_RNG, '').to_i
101
+
102
+ if v > 0 && v <= UPC_MAX
103
+ v
104
+ else
105
+ bad! "provided value of #{raw.inspect} is not permitted, it must " \
106
+ "be a valid UPC with a numerical value between zero and #{UPC_MAX}."
107
+ end
108
+ end
109
+ end
110
+ register :upc, UPC
111
+
112
+ class InventoryID < Type
113
+ INVENTORY_ID_RNG = /\A([0-9]{1,12})\-([0-9]{1,3})\Z/
114
+
115
+ def cast!
116
+ v = raw.to_s.strip
117
+
118
+ if v =~ INVENTORY_ID_RNG
119
+ [$1.to_i, $2.to_i]
120
+ else
121
+ bad! "provided value of #{raw.inspect} is not a permitted " \
122
+ "inventory ID, it must be a product ID and store ID separated by a " \
123
+ "dash, eg: 232744-94"
124
+ end
125
+ end
126
+ end
127
+ register :inventory_id, InventoryID
128
+
129
+ class CategoryDepth < Type
130
+ def cast!
131
+ v = raw.to_s.to_i
132
+
133
+ if v >= 0 && v <= 2
134
+ v
135
+ else
136
+ bad! "provided value of #{raw.inspect} is not a permitted category " \
137
+ "depth, it must be an integer value of 0, 1, or 2."
138
+ end
139
+ end
140
+ end
141
+ register :category_depth, CategoryDepth
142
+
143
+ class Latitude < Type
144
+ def cast!
145
+ case v = raw.to_f
146
+ when 0.0
147
+ bad! "provided value of #{raw.inspect} is not permitted, it must " \
148
+ "be a valid latitude in the range of -90.0 to 90.0"
149
+ when -90..90
150
+ v
151
+ else
152
+ bad! "provided value of #{raw.inspect} is not permitted, it must " \
153
+ "be a valid latitude in the range of -90.0 to 90.0"
154
+ end
155
+ end
156
+ end
157
+ register :latitude, Latitude
158
+
159
+ class Longitude < Type
160
+ def cast!
161
+ case v = raw.to_f
162
+ when 0.0
163
+ bad! "provided value of #{raw.inspect} is not permitted, it must " \
164
+ "be a valid longitude within -180.0 to 180.0"
165
+ when -180..180
166
+ v
167
+ else
168
+ bad! "provided value of #{raw.inspect} is not permitted, it must " \
169
+ "be a valid longitude within -180.0 to 180.0"
170
+ end
171
+ end
172
+ end
173
+ register :longitude, Longitude
174
+
175
+ class Whole < Type
176
+ def cast!
177
+ if (v = raw.to_i) >= 0
178
+ v
179
+ else
180
+ bad! "provided value of #{raw.inspect} is not permitted, it must " \
181
+ "be a non-negative number"
182
+ end
183
+ end
184
+ end
185
+ register :whole, Whole
186
+
187
+ class EnumSort < Type
188
+ def cast!
189
+ case raw.downcase
190
+ when 'asc'
191
+ :asc
192
+ when 'desc'
193
+ :desc
194
+ else
195
+ bad! "provided value of #{raw.inspect} is not permitted, permitted " \
196
+ "values are: \"asc\", or \"desc\""
197
+ end
198
+ end
199
+ end
200
+ register :enum_sort, EnumSort
201
+
202
+ class Date < Type
203
+ DATE_RNG = /[12]{1}[0-9]{3}\-[10]{1}[0-9]{1}\-[0123]{1}[0-9]{1}/
204
+
205
+ def cast!
206
+ if raw =~ DATE_RNG
207
+ Date.parse($1)
208
+ else
209
+ bad! "provided value of #{raw.inspect} is not permitted, it must " \
210
+ "be an ISO 8601 formatted date: YYYY-MM-DD"
211
+ end
212
+ end
213
+ end
214
+ register :date, Date
215
+ end
216
+ end
@@ -0,0 +1,19 @@
1
+ require 'active_support'
2
+
3
+ module Magiq
4
+ module Utils
5
+ module_function
6
+
7
+ def pluralize(string)
8
+ ActiveSupport::Inflector.pluralize(string)
9
+ end
10
+
11
+ def blank?(val)
12
+ val.respond_to?(:empty?) ? !!val.empty? : !val
13
+ end
14
+
15
+ def present?(val)
16
+ !blank?(val)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,3 @@
1
+ module Magiq
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'magiq/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'magiq'
8
+ spec.version = Magiq::VERSION
9
+ spec.authors = ['Carsten Nielsen']
10
+ spec.email = ['heycarsten@gmail.com']
11
+
12
+ spec.summary = %q{Magically turn query parameters into ActiveRecord scopes}
13
+ spec.description = %q{Magiq enables you to declaratively specifiy queries that translate into ActiveRecord Arel scopes}
14
+ spec.homepage = 'https://github.com/heycarsten/magiq'
15
+ spec.license = 'MIT'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+
21
+ spec.bindir = 'exe'
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.add_dependency 'activerecord', '>= 4.1'
26
+
27
+ spec.add_development_dependency 'bundler', '~> 1.14'
28
+ spec.add_development_dependency 'rake', '~> 10.0'
29
+ spec.add_development_dependency 'rspec', '~> 3.0'
30
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: magiq
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Carsten Nielsen
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-03-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '4.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.14'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.14'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ description: Magiq enables you to declaratively specifiy queries that translate into
70
+ ActiveRecord Arel scopes
71
+ email:
72
+ - heycarsten@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".gitignore"
78
+ - ".rspec"
79
+ - ".travis.yml"
80
+ - CODE_OF_CONDUCT.md
81
+ - Gemfile
82
+ - LICENSE.md
83
+ - README.md
84
+ - Rakefile
85
+ - bin/console
86
+ - bin/setup
87
+ - lib/magiq.rb
88
+ - lib/magiq/builder.rb
89
+ - lib/magiq/param.rb
90
+ - lib/magiq/query.rb
91
+ - lib/magiq/types.rb
92
+ - lib/magiq/utils.rb
93
+ - lib/magiq/version.rb
94
+ - magiq.gemspec
95
+ homepage: https://github.com/heycarsten/magiq
96
+ licenses:
97
+ - MIT
98
+ metadata: {}
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubyforge_project:
115
+ rubygems_version: 2.6.8
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: Magically turn query parameters into ActiveRecord scopes
119
+ test_files: []