magiq 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []