sql_builder 1.3.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: c310ff0fcce5f5e392e9e8ec98895fe44224154c80d6681b83fb9565a9366af7
4
+ data.tar.gz: 79f2fc22344f7d1d4984d66f107e828d2ab386dd99c37e1b924b3a7499ee0996
5
+ SHA512:
6
+ metadata.gz: f3618224985c8813acc7098aa036028d4796c4a59af20d16170af46cb6ecaabf790c1d0b93d5677ff5c0988835b1f51246c1f6b4b9851ec2e4163def2f0485dc
7
+ data.tar.gz: 7a56ce17bf57b650dbac08e6d9b4dcc0e2dc2c4b9c02950adcb3dd7076aba67436ae6b33829531879624dd99f4d3f704a9cab9d7a43955e1ad7de90210d7625d
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /.idea/
data/.rakeTasks ADDED
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <Settings><!--This file was automatically generated by Ruby plugin.
3
+ You are allowed to:
4
+ 1. Remove rake task
5
+ 2. Add existing rake tasks
6
+ To add existing rake tasks automatically delete this file and reload the project.
7
+ --><RakeGroup description="" fullCmd="" taksId="rake"><RakeTask description="Build sql_builder-0.1.0.gem into the pkg directory" fullCmd="build" taksId="build" /><RakeTask description="Remove any temporary products" fullCmd="clean" taksId="clean" /><RakeTask description="Remove any generated files" fullCmd="clobber" taksId="clobber" /><RakeTask description="Build and install sql_builder-0.1.0.gem into system gems" fullCmd="install" taksId="install" /><RakeGroup description="" fullCmd="" taksId="install"><RakeTask description="Build and install sql_builder-0.1.0.gem into system gems without network access" fullCmd="install:local" taksId="local" /></RakeGroup><RakeTask description="Create tag v0.1.0 and build and push sql_builder-0.1.0.gem to rubygems.org" fullCmd="release[remote]" taksId="release[remote]" /><RakeTask description="Run tests" fullCmd="test" taksId="test" /><RakeTask description="" fullCmd="default" taksId="default" /><RakeTask description="" fullCmd="release" taksId="release" /><RakeGroup description="" fullCmd="" taksId="release"><RakeTask description="" fullCmd="release:guard_clean" taksId="guard_clean" /><RakeTask description="" fullCmd="release:rubygem_push" taksId="rubygem_push" /><RakeTask description="" fullCmd="release:source_control_push" taksId="source_control_push" /></RakeGroup></RakeGroup></Settings>
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ ruby-2.6.8
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.6.3
7
+ before_install: gem install bundler -v 1.17.3
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in sql_builder.gemspec
6
+
7
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,43 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sql_builder (1.3.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ activemodel (6.0.4)
10
+ activesupport (= 6.0.4)
11
+ activerecord (6.0.4)
12
+ activemodel (= 6.0.4)
13
+ activesupport (= 6.0.4)
14
+ activesupport (6.0.4)
15
+ concurrent-ruby (~> 1.0, >= 1.0.2)
16
+ i18n (>= 0.7, < 2)
17
+ minitest (~> 5.1)
18
+ tzinfo (~> 1.1)
19
+ zeitwerk (~> 2.2, >= 2.2.2)
20
+ concurrent-ruby (1.1.9)
21
+ i18n (1.8.10)
22
+ concurrent-ruby (~> 1.0)
23
+ minitest (5.14.3)
24
+ pg (0.21.0)
25
+ rake (13.0.3)
26
+ thread_safe (0.3.6)
27
+ tzinfo (1.2.9)
28
+ thread_safe (~> 0.1)
29
+ zeitwerk (2.4.2)
30
+
31
+ PLATFORMS
32
+ ruby
33
+
34
+ DEPENDENCIES
35
+ activerecord (>= 5.2)
36
+ bundler (~> 2.1)
37
+ minitest (~> 5.0)
38
+ pg (~> 0.18)
39
+ rake (~> 13)
40
+ sql_builder!
41
+
42
+ BUNDLED WITH
43
+ 2.3.6
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Andy Selvig
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,156 @@
1
+ # SqlBuilder
2
+
3
+ SqlBuilder is a small, simple Ruby library that lets you compose SQL queries using a fluent, builder syntax.
4
+
5
+ This is *not* an ORM and does require any schema definition or mapping.
6
+ You simply specify a query and SqlBuilder will generate an ad-hoc, read-only Ruby class to represent the result.
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem 'sql_builder', git: 'git@github.com:Terrier-Tech/sql_builder.git'
14
+ ```
15
+
16
+ And then execute:
17
+
18
+ $ bundle
19
+
20
+ ## Usage
21
+
22
+ SqlBuilder uses a chainable builder syntax to make constructing queries more flexible than using plain strings.
23
+ It also automatically handles things like applying prefixes to column names and managing aliases.
24
+
25
+ ```ruby
26
+ # sql builder example
27
+ locs = SqlBuilder.new
28
+ .select(%w(city state), 'loc', 'location_') # columns, table alias, prefix
29
+ .select(%w(first_name last_name), 'tech', 'technician_')
30
+ .from('locations', 'loc') # table name, alias
31
+ .left_join('users', 'tech', 'tech.id = loc.primary_technician_id') # table name, alias, clause
32
+ .where("loc.zip = ?", '55124') # plain string clause
33
+ .where("tech.first_name = ?", 'Billy')
34
+ .exec
35
+
36
+ # returns an array of POROs with attributes location_city, location_state,
37
+ # technician_first_name, technician_last_name
38
+ locs.each do |loc|
39
+ puts "#{loc.technician_first_name} in #{loc.location_city}"
40
+ end
41
+ ```
42
+
43
+ Attribute types will be automatically inferred from the names, including converting from cents to dollars.
44
+
45
+ Since SqlBuilder stores all of the components of the query and is a plain Ruby object, it can also be composed conditionally:
46
+
47
+ ```ruby
48
+ builder = SqlBuilder.new
49
+ .select(%w(first_name last_name), 'tech')
50
+ .from('users', 'tech')
51
+ .where('tech._state = 0')
52
+ .where("tech.role = ?", 'technician')
53
+
54
+ if branch_id
55
+ builder.where("tech.branch_id = ?", branch_id)
56
+ end
57
+
58
+ techs = builder.exec
59
+ ```
60
+
61
+
62
+ ### Important Notes
63
+
64
+ First, **SqlBuilder now sanitizes input!**, this utilizes ActiveRecord's(v4.2) ```sanitize_sql_array``` method to do the heavy lifting. You may opt for a more recent version of ActiveRecord in which case it will use the ```sanitize``` method.
65
+
66
+ Second, SqlBuilder assumes you have ActiveRecord around in order to use `SqlBuilder#exec`.
67
+ If this isn't the case, you can always get the raw SQL using `SqlBuilder#to_sql`.
68
+
69
+ ### Joins
70
+
71
+ SqlBuilder offers 4 join commands `left_join`, `inner_join`, `outer_join`, and `right_join`. Any of these can take one of three combinations of arguments (all strings):
72
+
73
+ ```ruby
74
+ # base_query
75
+ inspection_items = SqlBuilder.new
76
+ .select('inspection_items.*')
77
+
78
+ # the following three examples will add the following join clause: INNER JOIN work_orders AS wo ON wo.id = inspection_items.work_order_id
79
+ # three args can be used to add any join (parent or child)
80
+ inspection_items.inner_join('work_orders', 'wo', 'wo.id = inspection_items.work_order_id')
81
+
82
+ # two args can only be used to add a parent table join
83
+ inspection_items.inner_join('work_orders AS wo', 'inspection_items.work_order_id')
84
+
85
+ # one arg can only be used to add a parent table join
86
+ inspection_items.inner_join('work_orders AS wo')
87
+
88
+
89
+ # note that the 2 arg variation can be used to join a table that does not have a traditional foreign_key
90
+ work_orders = SqlBuilder.new
91
+ .select('work_orders.*')
92
+
93
+ work_orders.inner_join('users', 'work_orders.technician_id')
94
+ ```
95
+
96
+
97
+ ### Computed Columns
98
+
99
+ You can compute additional columns on the result set.
100
+ Use the `compute_column` method with the name of the new column and a block that accepts each row:
101
+
102
+ ```ruby
103
+ locs.compute_column 'age' do |row|
104
+ Time.now - row.created_at
105
+ end
106
+ ```
107
+
108
+ Alternatively, you can use `compute_columns` to compute multiple columns at once:
109
+
110
+ ```ruby
111
+ locs.compute_columns do |row|
112
+ geo = row.geo.parse_geo_point
113
+ {latitude: geo.latitude, longitude: geo.longitude}
114
+ end
115
+ ```
116
+
117
+ ### as_raw
118
+
119
+ Optionally, you can call the `as_raw' method on a SqlBuilder instance to have it return hashes instead of objects as the result:
120
+
121
+ ```ruby
122
+ locs = SqlBuilder.new
123
+ .select(%w(city state), 'loc', 'location_')
124
+ .select(%w(first_name last_name), 'tech', 'technician_')
125
+ .from('locations')
126
+ .inner_join('users', 'tech', 'tech.id = loc.primary_technician_id')
127
+ .where("zip = ?", '55124')
128
+ .as_raw
129
+ .exec
130
+
131
+ locs.each do |loc|
132
+ puts "#{loc['technician_first_name']} in #{loc['location_city']}"
133
+ end
134
+ ```
135
+
136
+ NOTE: for historical reasons, `.as_raw` is enabled by default in Clypboard. Use `.as_objects` to enable the Ruby object generation.
137
+
138
+ ### Dialects
139
+
140
+ Currently, SqlBuilder only supports PostgresQL and MS-SQL dialects.
141
+ You can set the dialect by calling `SqlBuilder#dialect` with either `:psql` (default) or `mssql`.
142
+
143
+
144
+ ## Development
145
+
146
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
147
+
148
+ 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 tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
149
+
150
+ ## Contributing
151
+
152
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Terrier-Tech/sql_builder. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
153
+
154
+ ## License
155
+
156
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task :default => :test
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "sql/builder"
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__)
data/bin/setup ADDED
@@ -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,383 @@
1
+ require_relative './query_row'
2
+
3
+ # wraps the result of a SQL query into a container of objects
4
+ # a new QueryResult will contain an array of objects implementing a
5
+ # custom class derived from QueryRow containing convenience methods to access values
6
+ class QueryResult
7
+
8
+ attr_reader :columns, :column_types
9
+
10
+ def initialize(raw)
11
+ @columns = []
12
+ @column_types = {}
13
+ if raw.empty?
14
+ @array = raw
15
+ else
16
+ @row_class = create_row_class raw.first
17
+ @array = raw.map do |raw_row|
18
+ @row_class.new self, raw_row
19
+ end
20
+ end
21
+ end
22
+
23
+ def each
24
+ return @array.to_enum(:each) unless block_given?
25
+ @array.each do |row|
26
+ yield row
27
+ end
28
+ end
29
+
30
+ def map
31
+ return @array.to_enum(:map) unless block_given?
32
+ @array.map do |row|
33
+ yield row
34
+ end
35
+ end
36
+
37
+ def select
38
+ return @array.to_enum(:select) unless block_given?
39
+ @array.select do |row|
40
+ yield row
41
+ end
42
+ end
43
+
44
+ def group_by
45
+ return @array.to_enum(:group_by) unless block_given?
46
+ @array.group_by do |row|
47
+ yield row
48
+ end
49
+ end
50
+
51
+ def index_by
52
+ return @array.to_enum(:index_by) unless block_given?
53
+ @array.index_by do |row|
54
+ yield row
55
+ end
56
+ end
57
+
58
+ def sort_by
59
+ self.to_a.sort_by
60
+ end
61
+
62
+ def to_a
63
+ @array
64
+ end
65
+
66
+ def count
67
+ @array.count
68
+ end
69
+
70
+ def length
71
+ @array.count
72
+ end
73
+
74
+ def first
75
+ @array.first
76
+ end
77
+
78
+ def empty?
79
+ @array.empty?
80
+ end
81
+
82
+ def column_names
83
+ @columns.map{|c| c[:name]}
84
+ end
85
+
86
+ def set_column_type(column, type)
87
+ unless self.column_names.index column.to_s
88
+ raise "No column '#{column}' in the result"
89
+ end
90
+ unless COLUMN_TYPES.index type.to_sym
91
+ raise "Invalid column type '#{type}', must be one of #{COLUMN_TYPES.map(&:to_s).join(', ')}"
92
+ end
93
+ @column_types[column.to_s] = type
94
+ define_column_method @row_class, column.to_s
95
+ end
96
+
97
+ def set_column_order(order)
98
+ new_columns = []
99
+ order.each do |name|
100
+ @columns.each do |set|
101
+ if set[:name] == name
102
+ new_columns.append set
103
+ end
104
+ end
105
+ end
106
+ @columns = new_columns
107
+ end
108
+
109
+ def as_json(options={})
110
+ @array.map do |row|
111
+ row.as_json options
112
+ end
113
+ end
114
+
115
+ def to_csv
116
+ return '' if @array.empty?
117
+ CSV.generate do |csv|
118
+ csv << column_names
119
+ self.each do |row|
120
+ csv << @columns.map{|col| row.serialize_value(col)}
121
+ end
122
+ end
123
+ end
124
+
125
+ # computes a new column by evaluating a block for every row
126
+ def compute_column(name, type=nil)
127
+ return unless @row_class # empty result
128
+ name_s = name.to_s
129
+ define_column_method @row_class, name_s, type
130
+ each do |row|
131
+ new_val = yield row
132
+ row.send "#{name}=", new_val
133
+ end
134
+ end
135
+
136
+ # computes new columns by evaluating a block for every row
137
+ # the block should return a hash of new column values
138
+ def compute_columns
139
+ return unless @row_class # empty result
140
+ columns_defined = @columns.pluck(:name).map { |key| [key.to_s, true] }.to_h
141
+ each do |row|
142
+ new_vals = yield row
143
+ new_vals.each_key do |name|
144
+ name=name.to_s
145
+ unless columns_defined[name]
146
+ define_column_method @row_class, name
147
+ columns_defined[name]=true
148
+ end
149
+ end
150
+ new_vals.each do |key, val|
151
+ row.send "#{key}=", val
152
+ end
153
+ end
154
+ end
155
+
156
+ def remove_column(name)
157
+ @columns.delete_if {|c| c[:name] == name.to_s}
158
+ end
159
+
160
+ TRUE_STRINGS = %w(1 true t)
161
+
162
+ COLUMN_TYPES = %i(raw array bool dollars time date integer float json geo string)
163
+
164
+ def define_column_method(row_class, name, type=nil)
165
+ name_s = name.to_s
166
+ type ||= @column_types[name_s] || QueryResult.column_type(name_s)
167
+ unless COLUMN_TYPES.index type.to_sym
168
+ raise "Invalid column type '#{type}', must be one of #{COLUMN_TYPES.map(&:to_s).join(', ')}"
169
+ end
170
+ @columns << {name: name_s, type: type}
171
+ case type
172
+ when :raw
173
+ row_class.define_method name do
174
+ self.instance_variable_get('@raw')[name_s]
175
+ end
176
+ row_class.define_method "#{name}=" do |val|
177
+ self.instance_variable_get('@raw')[name_s] = val
178
+ end
179
+ when :array
180
+ row_class.define_method name do
181
+ val = self.instance_variable_get('@raw')[name_s]
182
+ if val.is_a? String
183
+ # this might not be the best implementation
184
+ val.gsub('{', '').gsub('}', '').gsub('"', '').split(',')
185
+ else
186
+ val
187
+ end
188
+ end
189
+ row_class.define_method "#{name}=" do |val|
190
+ self.instance_variable_get('@raw')[name_s] = val
191
+ end
192
+ when :bool
193
+ row_class.define_method name do
194
+ raw = self.instance_variable_get('@raw')[name_s]
195
+ TRUE_STRINGS.include?(raw.to_s.downcase)
196
+ end
197
+ row_class.define_method "#{name}=" do |val|
198
+ self.instance_variable_get('@raw')[name_s] = TRUE_STRINGS.include?(val.to_s.downcase)
199
+ end
200
+ when :dollars
201
+ row_class.define_method name do
202
+ self.instance_variable_get('@raw')[name_s].to_i/100.0
203
+ end
204
+ row_class.define_method "#{name}=" do |val|
205
+ self.instance_variable_get('@raw')[name_s] = ((val || 0)*100).to_i
206
+ end
207
+ when :time
208
+ row_class.define_method name do
209
+ raw_time = self.instance_variable_get('@raw')[name_s]
210
+ case raw_time
211
+ when String
212
+ # always return times in the local timezone
213
+ if SqlBuilder.default_timezone == :local
214
+ time = Time.parse raw_time
215
+ else # assume database times are in UTC
216
+ time = raw_time.in_time_zone('UTC').getlocal
217
+ end
218
+ self.instance_variable_get('@raw')[name_s] = time
219
+ time
220
+ else
221
+ raw_time
222
+ end
223
+ end
224
+ row_class.define_method "#{name}=" do |val|
225
+ self.instance_variable_get('@raw')[name_s] = val
226
+ end
227
+ when :date
228
+ row_class.define_method name do
229
+ raw_date = self.instance_variable_get('@raw')[name_s]
230
+ case raw_date
231
+ when String
232
+ date = Date.parse(raw_date)
233
+ self.instance_variable_get('@raw')[name_s] = date
234
+ date
235
+ else
236
+ raw_date
237
+ end
238
+ end
239
+ row_class.define_method "#{name}=" do |val|
240
+ self.instance_variable_get('@raw')[name_s] = val
241
+ end
242
+ when :integer
243
+ row_class.define_method name do
244
+ raw = self.instance_variable_get('@raw')[name_s]
245
+ if raw.blank?
246
+ nil
247
+ elsif raw.is_a? Integer
248
+ raw
249
+ elsif raw =~ /^-*\d+$/
250
+ i = raw.to_i
251
+ self.instance_variable_get('@raw')[name_s] = i
252
+ i
253
+ else
254
+ raw
255
+ end
256
+ end
257
+ row_class.define_method "#{name}=" do |val|
258
+ self.instance_variable_get('@raw')[name_s] = val.to_i
259
+ end
260
+ when :float
261
+ row_class.define_method name do
262
+ self.instance_variable_get('@raw')[name_s].to_f
263
+ end
264
+ row_class.define_method "#{name}=" do |val|
265
+ self.instance_variable_get('@raw')[name_s] = val.to_f
266
+ end
267
+ when :json
268
+ row_class.define_method name do
269
+ raw = self.instance_variable_get('@raw')[name_s]
270
+ raw ? JSON.parse(raw) : nil
271
+ end
272
+ row_class.define_method "#{name}=" do |val|
273
+ self.instance_variable_get('@raw')[name_s] = val&.as_json
274
+ end
275
+ when :geo
276
+ row_class.define_method name do
277
+ self.instance_variable_get('@raw')[name_s]&.parse_geo_point
278
+ end
279
+ row_class.define_method "#{name}=" do |val|
280
+ self.instance_variable_get('@raw')[name_s] = val
281
+ end
282
+ else # string
283
+ row_class.define_method name do
284
+ self.instance_variable_get('@raw')[name_s]
285
+ end
286
+ row_class.define_method "#{name}=" do |val|
287
+ self.instance_variable_get('@raw')[name_s] = val.to_s
288
+ end
289
+ end
290
+ end
291
+
292
+
293
+ private
294
+
295
+ ARRAY_SUFFIXES = %w(_array tags ies recipients _ids)
296
+ BOOL_PREFIXES = %w(is_)
297
+ RAW_SUFFIXES = %w(_items lanes certs)
298
+ DOLLARS_SUFFIXES = %w(price dollars total tax _value amount balance)
299
+ TIME_SUFFIXES = %w(_at time)
300
+ DATE_SUFFIXES = %w(_date)
301
+ INTEGER_EXACT = %w(x y value)
302
+ INTEGER_SUFFIXES = %w(number count duration _i)
303
+ INTEGER_PREFIXES = %w(days_since days_until)
304
+ FLOAT_SUFFIXES = %w(_m _miles distance latitude longitude _score _f)
305
+ JSON_SUFFIXES = %w(object_array json_array json weather)
306
+ GEO_SUFFIXES = %w(geo)
307
+
308
+ def self.column_type(key)
309
+ key_s = key.to_s
310
+ if key_s == '_state'
311
+ return :integer
312
+ end
313
+ RAW_SUFFIXES.each do |suffix|
314
+ if key_s.end_with?(suffix)
315
+ return :raw
316
+ end
317
+ end
318
+ JSON_SUFFIXES.each do |suffix| # do json before arrays so we can use *object_array and *json_array
319
+ if key_s.end_with?(suffix)
320
+ return :json
321
+ end
322
+ end
323
+ ARRAY_SUFFIXES.each do |suffix|
324
+ if key_s.end_with?(suffix)
325
+ return :array
326
+ end
327
+ end
328
+ BOOL_PREFIXES.each do |suffix|
329
+ if key_s.start_with?(suffix)
330
+ return :bool
331
+ end
332
+ end
333
+ DOLLARS_SUFFIXES.each do |suffix|
334
+ if key_s.end_with?(suffix)
335
+ return :dollars
336
+ end
337
+ end
338
+ TIME_SUFFIXES.each do |suffix|
339
+ if key_s.end_with?(suffix)
340
+ return :time
341
+ end
342
+ end
343
+ DATE_SUFFIXES.each do |suffix|
344
+ if key_s.end_with?(suffix)
345
+ return :date
346
+ end
347
+ end
348
+ if INTEGER_EXACT.index(key_s)
349
+ return :integer
350
+ end
351
+ INTEGER_SUFFIXES.each do |suffix|
352
+ if key_s.end_with?(suffix)
353
+ return :integer
354
+ end
355
+ end
356
+ INTEGER_PREFIXES.each do |prefix|
357
+ if key_s.start_with?(prefix)
358
+ return :integer
359
+ end
360
+ end
361
+ FLOAT_SUFFIXES.each do |suffix|
362
+ if key_s.end_with?(suffix)
363
+ return :float
364
+ end
365
+ end
366
+ GEO_SUFFIXES.each do |suffix|
367
+ if key_s.end_with?(suffix)
368
+ return :geo
369
+ end
370
+ end
371
+ :string
372
+ end
373
+
374
+ def create_row_class(template)
375
+ this = self
376
+ Class.new QueryRow do
377
+ template.each do |key, value|
378
+ this.define_column_method self, key
379
+ end
380
+ end
381
+ end
382
+
383
+ end
@@ -0,0 +1,50 @@
1
+ # base class for objects representing a single row in a query result
2
+ class QueryRow
3
+
4
+ attr_reader :raw
5
+
6
+ def initialize(result, raw)
7
+ @result = result
8
+ @raw = raw
9
+ end
10
+
11
+ def [](key)
12
+ @raw[key.to_s]
13
+ end
14
+
15
+ def []=(key, value)
16
+ @raw[key.to_s] = value
17
+ end
18
+
19
+ def inspect
20
+ '{' + @result.columns.map{|col| "#{col[:name]}=#{self.send(col[:name])}"}.join(', ') + '}'
21
+ end
22
+
23
+ def serialize_value(column)
24
+ value = self.send(column[:name])
25
+ case column[:type]
26
+ when :time
27
+ value&.to_s
28
+ else
29
+ value
30
+ end
31
+ end
32
+
33
+ def as_json(options={})
34
+ attrs = {}
35
+ @result.columns.each do |col|
36
+ attrs[col[:name]] = self.serialize_value col
37
+ end
38
+ attrs
39
+ end
40
+
41
+ def keys
42
+ @result.column_names
43
+ end
44
+
45
+ # this is needed so that we can call defined_method outside of the subclass definition
46
+ class << self
47
+ public :define_method
48
+ end
49
+
50
+ end
@@ -0,0 +1,5 @@
1
+ module Sql
2
+ module Builder
3
+ VERSION = "1.3.0"
4
+ end
5
+ end
@@ -0,0 +1,329 @@
1
+ require "sql_builder/version"
2
+ require "sql_builder/query_result"
3
+ require "active_record"
4
+
5
+ # Monkey Patch
6
+ module ActiveRecord
7
+ class Base
8
+ class << self
9
+ public :sanitize_sql_array
10
+ end
11
+ end
12
+ end
13
+
14
+ # provides a builder interface for creating SQL queries
15
+ class SqlBuilder
16
+ DEFAULT_LIMIT = 10000
17
+
18
+ # attributes that are arrays and should be included in serialization
19
+ ARRAY_ATTRS = %w[selects clauses distincts froms joins order_bys group_bys havings withs]
20
+
21
+ attr_accessor(*ARRAY_ATTRS)
22
+
23
+ # attributes that are scalars and should be included in serialization
24
+ SCALAR_ATTRS = %w[the_limit make_objects row_offset fetch_next the_dialect]
25
+ attr_accessor(*SCALAR_ATTRS)
26
+
27
+ @@default_make_objects = true
28
+
29
+ def self.default_make_objects=(val)
30
+ @@default_make_objects = val
31
+ end
32
+
33
+ class << self
34
+ # This should mimic the behavior of ActiveRecord::Base.default_timezone
35
+ # i.e. it's either :utc (default) or :local
36
+ attr_accessor :default_timezone
37
+
38
+ @default_timezone = :utc
39
+ end
40
+
41
+ def initialize
42
+ ARRAY_ATTRS.each do |attr|
43
+ self.send "#{attr}=", []
44
+ end
45
+
46
+ @make_objects = @@default_make_objects
47
+ @the_limit = DEFAULT_LIMIT
48
+ @limit_warning = true
49
+ @row_offset = nil
50
+ @fetch_next = nil
51
+ @the_dialect = :psql
52
+ end
53
+
54
+
55
+ ## Dialects
56
+
57
+ DIALECTS = %i(psql mssql)
58
+
59
+ def dialect(new_dialect=nil)
60
+ if new_dialect.nil?
61
+ return @the_dialect # make this method act like a getter as well
62
+ end
63
+ new_dialect = new_dialect.to_sym
64
+ unless DIALECTS.index new_dialect
65
+ raise "Invalid dialect #{new_dialect}, must be one of: #{DIALECTS.join(', ')}"
66
+ end
67
+ @the_dialect = new_dialect
68
+ self
69
+ end
70
+
71
+ def from(table, as=nil)
72
+ if as
73
+ @froms << "#{table} AS #{as}"
74
+ else
75
+ @froms << table
76
+ end
77
+ self
78
+ end
79
+
80
+ def select(columns, table=nil, prefix=nil)
81
+ columns = [columns] unless columns.is_a? Array
82
+ table_part = table ? "#{table}." : ''
83
+ columns.each do |c|
84
+ statement = "#{table_part}#{c}"
85
+ if prefix
86
+ statement += " #{prefix}#{c}"
87
+ end
88
+ @selects << statement
89
+ end
90
+ self
91
+ end
92
+
93
+ def with(w)
94
+ @withs << w
95
+ self
96
+ end
97
+
98
+ def get_join_mode_vars(arg1, arg2, arg3)
99
+ # 1 and 2 arg options can only join parent tables
100
+ if arg2.nil? && arg3.nil?
101
+ # 'work_orders AS wo'
102
+ table = arg1.split(' ').first
103
+ as = arg1.split(' ').last
104
+
105
+ if self.froms.blank?
106
+ raise 'must declare a from statement to use 1 argument join'
107
+ end
108
+ child_table = self.froms.first.split(' ').last
109
+ foreign_key = "#{table.singularize}_id"
110
+ clause = "#{child_table}.#{foreign_key} = #{as}.id"
111
+ elsif arg3.nil?
112
+ # 'work_orders AS wo', 'inspection_items.work_order_id'
113
+ table = arg1.split(' ').first
114
+ as = arg1.split(' ').last
115
+ clause = "#{as}.id = #{arg2}"
116
+ else
117
+ table = arg1
118
+ as = arg2
119
+ clause = arg3
120
+ end
121
+ [table, as, clause]
122
+ end
123
+
124
+
125
+ def left_join(arg1, arg2=nil, arg3=nil)
126
+ table, as, clause = get_join_mode_vars arg1, arg2, arg3
127
+ @joins << "LEFT JOIN #{table} AS #{as} ON #{clause}"
128
+ self
129
+ end
130
+
131
+ def inner_join(arg1, arg2=nil, arg3=nil)
132
+ table, as, clause = get_join_mode_vars arg1, arg2, arg3
133
+ @joins << "INNER JOIN #{table} AS #{as} ON #{clause}"
134
+ self
135
+ end
136
+
137
+ def outer_join(arg1, arg2=nil, arg3=nil)
138
+ table, as, clause = get_join_mode_vars arg1, arg2, arg3
139
+ @joins << "LEFT OUTER JOIN #{table} AS #{as} ON #{clause}"
140
+ self
141
+ end
142
+
143
+ def right_join(arg1, arg2=nil, arg3=nil)
144
+ table, as, clause = get_join_mode_vars arg1, arg2, arg3
145
+ @joins << "RIGHT JOIN #{table} AS #{as} ON #{clause}"
146
+ self
147
+ end
148
+
149
+ def parse_where(clause)
150
+ clause.each_with_index do |entry, i|
151
+ if entry.is_a?(Array)
152
+ template = clause[0].split('?')
153
+ template = template.map.with_index {|phrase, index| index==i-1 ? phrase : phrase+'?' }
154
+ template.insert(i, '('+('?'*entry.length).split('').join(',')+')')
155
+ clause[0] = template.join('')
156
+ clause.delete_at(i)
157
+ clause.insert(i, *entry)
158
+ end
159
+ end
160
+ end
161
+
162
+ # Adds a where clause without any sanitization or substitution
163
+ # This is essentially for clauses containing a `?`
164
+ def where_raw(clause)
165
+ @clauses << clause
166
+ self
167
+ end
168
+
169
+ def where(*clause)
170
+ @clauses << sanitize(parse_where(clause))
171
+ self
172
+ end
173
+
174
+ def having(clause)
175
+ @havings << clause
176
+ self
177
+ end
178
+
179
+ def group_by(expression)
180
+ @group_bys << expression
181
+ self
182
+ end
183
+
184
+ def order_by(expression)
185
+ @order_bys << expression
186
+ self
187
+ end
188
+
189
+ def limit(limit, limit_warning=false)
190
+ @limit_warning = limit_warning
191
+ @the_limit = limit
192
+ self
193
+ end
194
+
195
+ def offset(offset)
196
+ @row_offset = offset
197
+ self
198
+ end
199
+
200
+ def fetch(fetch)
201
+ @fetch_next = fetch
202
+ self
203
+ end
204
+
205
+ def distinct(distinct, table=nil)
206
+ distinct = [distinct] unless distinct.is_a? Array
207
+ table_part = table ? "#{table}." : ''
208
+ distinct.each do |d|
209
+ statement = "#{table_part}#{d}"
210
+ @distincts << statement
211
+ end
212
+ self
213
+ end
214
+
215
+ def to_sql
216
+ _distinct = ''
217
+ if @distincts and @distincts.count > 0
218
+ if @the_dialect == :mssql
219
+ _distinct += 'DISTINCT ('
220
+ _distinct += @distincts.join(', ')
221
+ _distinct += ')'
222
+ else
223
+ _distinct += 'DISTINCT ON ('
224
+ _distinct += @distincts.join(', ')
225
+ _distinct += ')'
226
+ end
227
+ end
228
+
229
+ withs_s = @withs.map do |w|
230
+ "WITH #{w}"
231
+ end.join(' ')
232
+
233
+ top_s = if @the_limit && @the_dialect == :mssql
234
+ "TOP #{@the_limit}"
235
+ else
236
+ ''
237
+ end
238
+
239
+ froms_s = @froms.empty? ? '' : "FROM #{@froms.join(', ')}"
240
+
241
+ s = "#{withs_s} SELECT #{top_s} #{_distinct} #{@selects.join(', ')} #{froms_s} #{@joins.join(' ')}"
242
+ if @clauses.length > 0
243
+ clauses_s = @clauses.map{|c| "(#{c})"}.join(' AND ')
244
+ s += " WHERE #{clauses_s}"
245
+ end
246
+ if @group_bys.length > 0
247
+ s += " GROUP BY #{@group_bys.join(', ')}"
248
+ end
249
+ if @havings.length > 0
250
+ s += " HAVING #{@havings.join(' AND ')}"
251
+ end
252
+ if @order_bys.length > 0
253
+ s += " ORDER BY #{@order_bys.join(', ')}"
254
+ end
255
+ if @the_limit && @the_dialect != :mssql
256
+ s += " LIMIT #{@the_limit}"
257
+ end
258
+ if @row_offset && @the_dialect == :mssql
259
+ s += " OFFSET #{@row_offset} ROWS"
260
+ elsif @row_offset
261
+ s += " OFFSET #{@row_offset}"
262
+ end
263
+ if @fetch_next && @the_dialect == :mssql
264
+ s += " FETCH NEXT #{@fetch_next} ROWS ONLY"
265
+ elsif @fetch_next
266
+ s += " FETCH FIRST #{@fetch_next} ROWS ONLY"
267
+ end
268
+ s
269
+ end
270
+
271
+ def sanitize(query)
272
+ query.each_with_index do |entry, i| #need to escape %
273
+ if entry.is_a?(String)
274
+ query[i] = entry.gsub('%', '%%')
275
+ end
276
+ end
277
+ if ActiveRecord::Base.respond_to? :sanitize_sql
278
+ ActiveRecord::Base.sanitize_sql query
279
+ else
280
+ ActiveRecord::Base.sanitize_sql_array query
281
+ end
282
+ end
283
+
284
+ def check_result_limit!(query)
285
+ if query.count == @the_limit and @limit_warning
286
+ raise "Query result has exactly #{@the_limit} results, which is the same as the limit"
287
+ end
288
+ query
289
+ end
290
+
291
+ def exec
292
+ results = ActiveRecord::Base.connection.execute(self.to_sql).to_a
293
+ if @make_objects
294
+ check_result_limit!(QueryResult.new results)
295
+ else
296
+ check_result_limit!(results)
297
+ end
298
+ end
299
+
300
+ def dup
301
+ other = SqlBuilder.new
302
+ (ARRAY_ATTRS + SCALAR_ATTRS).each do |attr|
303
+ other.send "#{attr}=", self.send(attr).dup
304
+ end
305
+ other.make_objects = @make_objects
306
+ other.the_limit = @the_limit
307
+ other
308
+ end
309
+
310
+ def as_raw
311
+ @make_objects = false
312
+ self
313
+ end
314
+
315
+ def as_objects
316
+ @make_objects = true
317
+ self
318
+ end
319
+
320
+ def self.from_raw(raw)
321
+ builder = SqlBuilder.new
322
+ (ARRAY_ATTRS + SCALAR_ATTRS).each do |attr|
323
+ if raw[attr]
324
+ builder.send "#{attr}=", raw[attr]
325
+ end
326
+ end
327
+ builder
328
+ end
329
+ end
@@ -0,0 +1,31 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "sql_builder/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "sql_builder"
8
+ spec.version = Sql::Builder::VERSION
9
+ spec.authors = ["Andy Selvig"]
10
+ spec.email = ["ajselvig@gmail.com"]
11
+
12
+ spec.summary = "Small Ruby library for building ad-hoc SQL queries."
13
+ spec.description = "Use builder pattern to create ad-hoc SQL queries that aren't tied to models but return read-only plain Ruby objects."
14
+ spec.homepage = "https://github.com/Terrier-Tech/sql_builder"
15
+ spec.license = "MIT"
16
+
17
+ # Specify which files should be added to the gem when it is released.
18
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
19
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
20
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
21
+ end
22
+ spec.bindir = "exe"
23
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
+ spec.require_paths = ["lib"]
25
+
26
+ spec.add_development_dependency "bundler", "~> 2.1"
27
+ spec.add_development_dependency "rake", "~> 13"
28
+ spec.add_development_dependency "minitest", "~> 5.0"
29
+ spec.add_development_dependency "activerecord", ">=5.2"
30
+ spec.add_development_dependency "pg", "~> 0.18"
31
+ end
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sql_builder
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Andy Selvig
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-04-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.1'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: activerecord
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '5.2'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '5.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pg
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.18'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.18'
83
+ description: Use builder pattern to create ad-hoc SQL queries that aren't tied to
84
+ models but return read-only plain Ruby objects.
85
+ email:
86
+ - ajselvig@gmail.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - ".gitignore"
92
+ - ".rakeTasks"
93
+ - ".ruby-version"
94
+ - ".travis.yml"
95
+ - Gemfile
96
+ - Gemfile.lock
97
+ - LICENSE.txt
98
+ - README.md
99
+ - Rakefile
100
+ - bin/console
101
+ - bin/setup
102
+ - lib/sql_builder.rb
103
+ - lib/sql_builder/query_result.rb
104
+ - lib/sql_builder/query_row.rb
105
+ - lib/sql_builder/version.rb
106
+ - sql_builder.gemspec
107
+ homepage: https://github.com/Terrier-Tech/sql_builder
108
+ licenses:
109
+ - MIT
110
+ metadata: {}
111
+ post_install_message:
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 3.0.3.1
127
+ signing_key:
128
+ specification_version: 4
129
+ summary: Small Ruby library for building ad-hoc SQL queries.
130
+ test_files: []