yesql 0.1.0 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39685fc3aed05a0bd16e721614bc55b31630fef3f8fdfb0c79e00662d0663da9
4
- data.tar.gz: b4f2fc397dd96d2fed5ae8327b4757eb18ccfccd54e426f82833ffa9d1261e04
3
+ metadata.gz: d85f1a89f06d3fa6d3a35471d895bce7860ed93988fa91d1f08149d349782e79
4
+ data.tar.gz: 4f6a7d1486495fe6291bddcfaa7d2fadbb051a79da98ba81c2e2a9958f3046ee
5
5
  SHA512:
6
- metadata.gz: 4ddb51e8b4a1a07355b5b8c58b4245686e9bad9f20b5c2ae63a4310d4de70512ebd2946c965dfd798b4d7dbd51364fc3e1d310aa10b1d10ce20aacf4dd597dfe
7
- data.tar.gz: 3c2db37304bd5894f740278c58fd79e20a93792d6405898c0f63257ee71216f43af9f4fdece369a4ebdc8aea1148e4a39bab78cacae0b429c78e8aa485e96f97
6
+ metadata.gz: 0faf664927b4ed1c605f33a79f6e65b93b2bddd2bfea6c4f71cdeec8b9ae425c17111363ce95a74c715b468d89fb7e68b3bdf3041c3feaf76fe366556bb6592f
7
+ data.tar.gz: a1e04806de2b8b367c95f0167032a664315a8c1de57636855b430f702c84b1884432895f6393742e273c65b3dd9d54b9e35ac2e4e4c5ae59ec637caeac64509e
data/.gitignore CHANGED
@@ -1 +1 @@
1
- *.gem
1
+ spec/minimalpg/log/test.log
data/Gemfile CHANGED
@@ -1,7 +1,9 @@
1
- source "https://rubygems.org"
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
2
4
 
3
- # Specify your gem's dependencies in yesql.gemspec
4
5
  gemspec
5
6
 
6
- gem "rake", "~> 12.0"
7
- gem "minitest", "~> 5.0"
7
+ group :docs do
8
+ gem 'yard'
9
+ end
@@ -0,0 +1,167 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ yesql (0.1.4)
5
+ rails (>= 5.0)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ actioncable (6.0.3.4)
11
+ actionpack (= 6.0.3.4)
12
+ nio4r (~> 2.0)
13
+ websocket-driver (>= 0.6.1)
14
+ actionmailbox (6.0.3.4)
15
+ actionpack (= 6.0.3.4)
16
+ activejob (= 6.0.3.4)
17
+ activerecord (= 6.0.3.4)
18
+ activestorage (= 6.0.3.4)
19
+ activesupport (= 6.0.3.4)
20
+ mail (>= 2.7.1)
21
+ actionmailer (6.0.3.4)
22
+ actionpack (= 6.0.3.4)
23
+ actionview (= 6.0.3.4)
24
+ activejob (= 6.0.3.4)
25
+ mail (~> 2.5, >= 2.5.4)
26
+ rails-dom-testing (~> 2.0)
27
+ actionpack (6.0.3.4)
28
+ actionview (= 6.0.3.4)
29
+ activesupport (= 6.0.3.4)
30
+ rack (~> 2.0, >= 2.0.8)
31
+ rack-test (>= 0.6.3)
32
+ rails-dom-testing (~> 2.0)
33
+ rails-html-sanitizer (~> 1.0, >= 1.2.0)
34
+ actiontext (6.0.3.4)
35
+ actionpack (= 6.0.3.4)
36
+ activerecord (= 6.0.3.4)
37
+ activestorage (= 6.0.3.4)
38
+ activesupport (= 6.0.3.4)
39
+ nokogiri (>= 1.8.5)
40
+ actionview (6.0.3.4)
41
+ activesupport (= 6.0.3.4)
42
+ builder (~> 3.1)
43
+ erubi (~> 1.4)
44
+ rails-dom-testing (~> 2.0)
45
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
46
+ activejob (6.0.3.4)
47
+ activesupport (= 6.0.3.4)
48
+ globalid (>= 0.3.6)
49
+ activemodel (6.0.3.4)
50
+ activesupport (= 6.0.3.4)
51
+ activerecord (6.0.3.4)
52
+ activemodel (= 6.0.3.4)
53
+ activesupport (= 6.0.3.4)
54
+ activestorage (6.0.3.4)
55
+ actionpack (= 6.0.3.4)
56
+ activejob (= 6.0.3.4)
57
+ activerecord (= 6.0.3.4)
58
+ marcel (~> 0.3.1)
59
+ activesupport (6.0.3.4)
60
+ concurrent-ruby (~> 1.0, >= 1.0.2)
61
+ i18n (>= 0.7, < 2)
62
+ minitest (~> 5.1)
63
+ tzinfo (~> 1.1)
64
+ zeitwerk (~> 2.2, >= 2.2.2)
65
+ builder (3.2.4)
66
+ coderay (1.1.3)
67
+ concurrent-ruby (1.1.7)
68
+ crass (1.0.6)
69
+ diff-lcs (1.4.4)
70
+ erubi (1.9.0)
71
+ globalid (0.4.2)
72
+ activesupport (>= 4.2.0)
73
+ i18n (1.8.5)
74
+ concurrent-ruby (~> 1.0)
75
+ loofah (2.7.0)
76
+ crass (~> 1.0.2)
77
+ nokogiri (>= 1.5.9)
78
+ mail (2.7.1)
79
+ mini_mime (>= 0.1.1)
80
+ marcel (0.3.3)
81
+ mimemagic (~> 0.3.2)
82
+ method_source (1.0.0)
83
+ mimemagic (0.3.5)
84
+ mini_mime (1.0.2)
85
+ mini_portile2 (2.4.0)
86
+ minitest (5.14.2)
87
+ mysql2 (0.5.3)
88
+ nio4r (2.5.4)
89
+ nokogiri (1.10.10)
90
+ mini_portile2 (~> 2.4.0)
91
+ pg (1.2.3)
92
+ pry (0.13.1)
93
+ coderay (~> 1.1)
94
+ method_source (~> 1.0)
95
+ rack (2.2.3)
96
+ rack-test (1.1.0)
97
+ rack (>= 1.0, < 3)
98
+ rails (6.0.3.4)
99
+ actioncable (= 6.0.3.4)
100
+ actionmailbox (= 6.0.3.4)
101
+ actionmailer (= 6.0.3.4)
102
+ actionpack (= 6.0.3.4)
103
+ actiontext (= 6.0.3.4)
104
+ actionview (= 6.0.3.4)
105
+ activejob (= 6.0.3.4)
106
+ activemodel (= 6.0.3.4)
107
+ activerecord (= 6.0.3.4)
108
+ activestorage (= 6.0.3.4)
109
+ activesupport (= 6.0.3.4)
110
+ bundler (>= 1.3.0)
111
+ railties (= 6.0.3.4)
112
+ sprockets-rails (>= 2.0.0)
113
+ rails-dom-testing (2.0.3)
114
+ activesupport (>= 4.2.0)
115
+ nokogiri (>= 1.6)
116
+ rails-html-sanitizer (1.3.0)
117
+ loofah (~> 2.3)
118
+ railties (6.0.3.4)
119
+ actionpack (= 6.0.3.4)
120
+ activesupport (= 6.0.3.4)
121
+ method_source
122
+ rake (>= 0.8.7)
123
+ thor (>= 0.20.3, < 2.0)
124
+ rake (13.0.1)
125
+ rspec (3.9.0)
126
+ rspec-core (~> 3.9.0)
127
+ rspec-expectations (~> 3.9.0)
128
+ rspec-mocks (~> 3.9.0)
129
+ rspec-core (3.9.2)
130
+ rspec-support (~> 3.9.3)
131
+ rspec-expectations (3.9.2)
132
+ diff-lcs (>= 1.2.0, < 2.0)
133
+ rspec-support (~> 3.9.0)
134
+ rspec-mocks (3.9.1)
135
+ diff-lcs (>= 1.2.0, < 2.0)
136
+ rspec-support (~> 3.9.0)
137
+ rspec-support (3.9.3)
138
+ sprockets (4.0.2)
139
+ concurrent-ruby (~> 1.0)
140
+ rack (> 1, < 3)
141
+ sprockets-rails (3.2.2)
142
+ actionpack (>= 4.0)
143
+ activesupport (>= 4.0)
144
+ sprockets (>= 3.0.0)
145
+ thor (1.0.1)
146
+ thread_safe (0.3.6)
147
+ tzinfo (1.2.7)
148
+ thread_safe (~> 0.1)
149
+ websocket-driver (0.7.3)
150
+ websocket-extensions (>= 0.1.0)
151
+ websocket-extensions (0.1.5)
152
+ yard (0.9.25)
153
+ zeitwerk (2.4.0)
154
+
155
+ PLATFORMS
156
+ ruby
157
+
158
+ DEPENDENCIES
159
+ mysql2 (~> 0.5.3)
160
+ pg (>= 0.18)
161
+ pry (~> 0.13.1)
162
+ rspec (~> 3.9.0)
163
+ yard
164
+ yesql!
165
+
166
+ BUNDLED WITH
167
+ 2.1.4
data/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Yesql
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/yesql`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Ruby library for using SQL in Ruby on Rails projects.
4
4
 
5
- TODO: Delete this and the text above, and describe your gem
5
+ YeSQL is a Ruby wrapper built on top of ActiveRecord to allow applications to execute "raw" SQL files from any directory within the application.
6
+
7
+ Heavily inspired by [krisajenkins/yesql](https://github.com/krisajenkins/yesql) Clojure library. You can see the [rationale](https://github.com/krisajenkins/yesql#rationale) of the library, which is the same for this one.
6
8
 
7
9
  ## Installation
8
10
 
@@ -22,17 +24,154 @@ Or install it yourself as:
22
24
 
23
25
  ## Usage
24
26
 
25
- TODO: Write usage instructions here
27
+ Write a SQL query in a file under the `app/yesql` directory named `users.sql`:
26
28
 
27
- ## Development
29
+ ```sql
30
+ -- app/yesql/users.sql
31
+ SELECT *
32
+ FROM users;
33
+ ```
34
+
35
+ Now open the Rails console, include the module and execute your query using `YeSQL`:
36
+
37
+ ```ruby
38
+ include YeSQL
39
+
40
+ YeSQL('users')
41
+ # users (0.9ms) SELECT * FROM users;
42
+ # => [[1, nil, nil, 2020-09-27 21:27:02.997839 UTC, 2020-09-27 21:27:02.997839 UTC]
43
+ ```
44
+
45
+ By default the output is an array of arrays, containing the value for every row.
46
+
47
+
48
+ ## Options
49
+
50
+
51
+ #### `bindings`
52
+
53
+
54
+ If your query has bindings, you can pass them as the second argument when calling `YeSQL`.
55
+
56
+ ```sql
57
+ -- app/yesql/top_10_users_in_x_country.sql
58
+ SELECT
59
+ :country AS country,
60
+ users.*
61
+ FROM users
62
+ WHERE country_id = :country_id
63
+ LIMIT :limit;
64
+ ```
65
+
66
+ When calling `YeSQL`:
67
+
68
+ ```ruby
69
+ YeSQL('top_10_users_in_x_country', { country: 'Cuba', country_id: 1, limit: 6 })
70
+ ```
71
+
72
+ - If the query doesn't have bindings, but they're provided they're just omitted.
73
+ - If the query has bindings, but nothing is provided, it raises a `NotImplementedError` exception.
74
+
75
+
76
+ #### `output`
77
+
78
+
79
+ If you need an output other than an array of arrays, you can use the `output` options. It accepts three values:
80
+
81
+ - `:columns` (or `'columns'`): returns an array containing the name of the columns returned from the query in the format `['column_a', 'column_b', ...]`.
82
+ - `:hash` (or `'hash'`): returns an array of hashes in the format `[{ column: value }, ...]`.
83
+ - `:rows` (or `'rows'`) DEFAULT: returns an array of arrays containing the result values from the query in the format `[[value1, value2, ...], ...]`.
84
+
85
+ Example:
86
+
87
+ ```ruby
88
+ YeSQL('users', output: :columns)
89
+ # => ['id', 'name', 'admin', 'created_at', 'updated_at']
90
+
91
+ YeSQL('users', output: :hash)
92
+ # => [{:id=>1, :name=>nil, :admin=>nil, :created_at=>2020-09-27 21:27:02.997839 UTC, :updated_at=>2020-09-27 21:27:02.997839 UTC}]
93
+
94
+ YeSQL('users', output: :rows)
95
+ # => [[1, nil, nil, 2020-09-27 21:27:02.997839 UTC, 2020-09-27 21:27:02.997839 UTC]]
96
+
97
+ YeSQL('users') # same as in `YeSQL('users', output: :rows)`
98
+ # => [[1, nil, nil, 2020-09-27 21:27:02.997839 UTC, 2020-09-27 21:27:02.997839 UTC]]
99
+ ```
100
+
101
+ - If an unsupported `output` value is provided it raises a `NotImplementedError` exception.
102
+ - If no `output` value is provided, the default is `rows`.
28
103
 
29
- 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.
30
104
 
31
- 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).
105
+ #### `prepare`
106
+
107
+ Using `prepare: true` it creates a prepared statement with the content of the SQL file:
108
+
109
+ ```ruby
110
+ ActiveRecord::Base.connection.execute('SELECT * FROM pg_prepared_statements').to_a
111
+ (0.Xms) SELECT * FROM pg_prepared_statements
112
+ # => []
113
+
114
+ YeSQL('top_10_users_in_x_country', prepare: true)
115
+ # ...
116
+
117
+ ActiveRecord::Base.connection.execute('SELECT * FROM pg_prepared_statements').to_a
118
+ (0.Xms) SELECT * FROM pg_prepared_statements
119
+ # => [{"name"=>"a1", "statement"=>"SELECT $1 AS country, users.* FROM users WHERE country_id = $2 LIMIT $3;", "prepare_time"=>2020-10-09 20:52:01.664121 +0000, "parameter_types"=>"{text,integer,bigint}", "from_sql"=>false}]
120
+ ```
121
+
122
+
123
+ #### `cache`
124
+
125
+ If you need to cache your query, you can use the `cache` option. `cache` must be a Hash containing __at least__ the `expires_in` key with an `ActiveSupport::Duration` object, e.g:
126
+
127
+ ```ruby
128
+ YeSQL('users', cache: { key: 'users', expires_in: 1.hour })
129
+ ```
130
+
131
+ That's enough to cache the result of the query for 1 hour with the cache key "users".
132
+
133
+ If no `key` key/value is used, then the cache key is the name of the file containing the SQL code, and
134
+
135
+ ```ruby
136
+ YeSQL('users', cache: { key: 'users', expires_in: 1.hour })
137
+ ```
138
+
139
+ is the same as
140
+
141
+ ```ruby
142
+ YeSQL('users', cache: { expires_in: 1.hour })
143
+ ```
144
+
145
+ ## Configuration
146
+
147
+ For default `YeSQL` looks for the _.sql_ files defined under the `app/yesql/` folder but you can update it to use any folder you need. For that you can create a Ruby file under the `config/initializers/` with the following content:
148
+
149
+ ```ruby
150
+ ::YeSQL.configure { |config| config.path = 'path' }
151
+ ```
152
+
153
+ After saving the file and restarting the server the files are going to be read from the given folder.
154
+
155
+ You can check at anytime what's the configuration path by inspecting the ::YeSQL `config` object:
156
+
157
+ ```ruby
158
+ ::YeSQL.config
159
+ # => #<YeSQL::Config::Configuration:0x00007feea1aa2ef8 @path="app/yesql">
160
+ ::YeSQL.config.path
161
+ # => "app/yesql"
162
+ ```
163
+
164
+
165
+ ## Development
166
+
167
+ - Clone the repository.
168
+ - Install the gem dependencies.
169
+ - Make sure to create both databases used in the dummy Rails applications (mysql, pg) in the spec/ folder.
170
+ - Run the tests.
32
171
 
33
172
  ## Contributing
34
173
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/yesql. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/yesql/blob/master/CODE_OF_CONDUCT.md).
174
+ Bug reports and pull requests are welcome on GitHub at https://github.com/sebastian-palma/yesql. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/sebastian-palma/yesql/blob/master/CODE_OF_CONDUCT.md).
36
175
 
37
176
 
38
177
  ## License
@@ -41,4 +180,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
41
180
 
42
181
  ## Code of Conduct
43
182
 
44
- Everyone interacting in the Yesql project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/yesql/blob/master/CODE_OF_CONDUCT.md).
183
+ Everyone interacting in the Yesql project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/sebastian-palma/yesql/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile CHANGED
@@ -1,10 +1 @@
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
1
+ # frozen_string_literal: true
@@ -1,6 +1,50 @@
1
- require "yesql/version"
1
+ # frozen_string_literal: true
2
2
 
3
- module Yesql
4
- class Error < StandardError; end
5
- # Your code goes here...
3
+ require 'yesql/version'
4
+ require 'yesql/config/configuration'
5
+ require 'yesql/query/performer'
6
+ require 'yesql/errors/cache_expiration_error'
7
+ require 'yesql/errors/file_path_does_not_exist_error'
8
+ require 'yesql/errors/no_bindings_provided_error'
9
+ require 'yesql/errors/output_argument_error'
10
+ require 'yesql/bindings/binder'
11
+
12
+ module YeSQL
13
+ include ::YeSQL::Config
14
+ include ::YeSQL::Errors::CacheExpirationError
15
+ include ::YeSQL::Errors::FilePathDoesNotExistError
16
+ include ::YeSQL::Errors::NoBindingsProvidedError
17
+ include ::YeSQL::Errors::OutputArgumentError
18
+
19
+ BIND_REGEX = /(?<!:):(\w+)(?=\b)/.freeze
20
+
21
+ # rubocop:disable Naming/MethodName
22
+ def YeSQL(file_path, bindings = {}, options = {})
23
+ output = options[:output] || :rows
24
+ cache = options[:cache] || {}
25
+
26
+ validate(bindings, cache, file_path, output)
27
+ execute(bindings, cache, file_path, output, options)
28
+ end
29
+ # rubocop:enable Naming/MethodName
30
+
31
+ private
32
+
33
+ def validate(bindings, cache, file_path, output)
34
+ validate_file_path_existence(file_path)
35
+ validate_statement_bindings(bindings, file_path)
36
+ validate_output_options(output)
37
+ validate_cache_expiration(cache[:expires_in]) unless cache.empty?
38
+ end
39
+
40
+ def execute(bindings, cache, file_path, output, options)
41
+ ::YeSQL::Query::Performer.new(
42
+ bindings: bindings,
43
+ bind_statement: ::YeSQL::Bindings::Binder.bind_statement(file_path, bindings),
44
+ cache: cache,
45
+ file_path: file_path,
46
+ output: output,
47
+ prepare: options[:prepare]
48
+ ).call
49
+ end
6
50
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yesql/utils/read'
4
+ require 'yesql/bindings/extractor'
5
+
6
+ module YeSQL
7
+ module Bindings
8
+ class Binder
9
+ def self.bind_statement(file_path, bindings)
10
+ ::YeSQL::Bindings::Extractor.new(bindings: bindings).call.tap do |extractor|
11
+ break ::YeSQL::Utils::Read.statement(file_path, readable: true)
12
+ .gsub(::YeSQL::BIND_REGEX) do |match|
13
+ extractor[match[/(\w+)/].to_sym][:bind][:vars]
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yesql'
4
+
5
+ module ::YeSQL
6
+ module Bindings
7
+ class Extract
8
+ include ::YeSQL::Common::Adapter
9
+
10
+ def initialize(indexed_bindings, hash, index, value)
11
+ @indexed_bindings = indexed_bindings
12
+ @hash = hash
13
+ @index = index
14
+ @value = value
15
+ end
16
+
17
+ def bind_vals
18
+ return [nil, value] unless array?
19
+
20
+ value.map { |bind| [nil, bind] }
21
+ end
22
+
23
+ def bind_vars
24
+ if mysql?
25
+ return '?' unless array?
26
+
27
+ Array.new(value.size, '?').join(', ')
28
+ elsif pg?
29
+ return "$#{last_val}" unless array?
30
+
31
+ value.map.with_index(bind_index) { |_, i| "$#{i}" }.join(', ')
32
+ end
33
+ end
34
+
35
+ def last_val
36
+ prev_last_val + current_val_size
37
+ end
38
+
39
+ def prev
40
+ return if first?
41
+
42
+ indexed_bindings[index - 2].first
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :hash, :index, :indexed_bindings, :value
48
+
49
+ def current_val_size
50
+ return value.size if array?
51
+
52
+ 1
53
+ end
54
+
55
+ def prev_last_val
56
+ return 0 if first?
57
+
58
+ hash[prev][:last_val]
59
+ end
60
+
61
+ def first?
62
+ index == 1
63
+ end
64
+
65
+ def array?
66
+ value.is_a?(Array)
67
+ end
68
+
69
+ def bind_index
70
+ return 1 if first?
71
+
72
+ prev_last_val + 1
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yesql/bindings/extract'
4
+
5
+ module YeSQL
6
+ module Bindings
7
+ class Extractor
8
+ def initialize(bindings:)
9
+ @bindings = bindings
10
+ @indexed_bindings = (bindings || {}).to_a
11
+ end
12
+
13
+ # rubocop:disable Metrics/MethodLength
14
+ def call
15
+ bindings.each_with_object({}).with_index(1) do |((key, value), hash), index|
16
+ hash[key] =
17
+ ::YeSQL::Bindings::Extract.new(indexed_bindings, hash, index, value).tap do |extract|
18
+ break {
19
+ bind: {
20
+ vals: extract.bind_vals,
21
+ vars: extract.bind_vars
22
+ },
23
+ last_val: extract.last_val,
24
+ match: key,
25
+ prev: extract.prev,
26
+ value: value
27
+ }
28
+ end
29
+ end
30
+ end
31
+ # rubocop:enable Metrics/MethodLength
32
+
33
+ private
34
+
35
+ attr_reader :bindings, :indexed_bindings
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YeSQL
4
+ module Bindings
5
+ module Utils
6
+ def statement_binds(extractor)
7
+ ::YeSQL::Utils::Read.statement(file_path, readable: true)
8
+ .scan(::YeSQL::BIND_REGEX).map do |(bind)|
9
+ extractor[bind.to_sym][:bind].values_at(:vals, :vars)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module ::YeSQL
6
+ module Common
7
+ module Adapter
8
+ extend Forwardable
9
+
10
+ # `adapter` might be a complex object, but
11
+ # for the sake of brevity it's just a string
12
+ def adapter
13
+ ::ActiveRecord::Base.connection.adapter_name
14
+ end
15
+
16
+ def mysql?
17
+ adapter == 'Mysql2'
18
+ end
19
+
20
+ def pg?
21
+ adapter == 'PostgreSQL'
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ::YeSQL
4
+ module Config
5
+ class Configuration
6
+ attr_accessor :path
7
+
8
+ DEFAULT_PATH = 'app/yesql'
9
+
10
+ def initialize
11
+ @path = DEFAULT_PATH
12
+ end
13
+ end
14
+ end
15
+
16
+ class << self
17
+ def config
18
+ @config ||= ::YeSQL::Configuration.new
19
+ end
20
+
21
+ def configure
22
+ yield config if block_given?
23
+ end
24
+
25
+ def reset_config
26
+ tap do |conf|
27
+ conf.configure do |configuration|
28
+ configuration.path = ::YeSQL::Configuration::DEFAULT_PATH
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YeSQL
4
+ module Errors
5
+ module CacheExpirationError
6
+ def validate_cache_expiration(expires_in)
7
+ raise ArgumentError, MESSAGE unless expires_in.is_a?(ActiveSupport::Duration)
8
+ end
9
+
10
+ MESSAGE = <<~MSG
11
+ Missing mandatory `expires_in` option for `cache`.
12
+
13
+ Can not cache the result of the query without an expiration date.
14
+ MSG
15
+ private_constant :MESSAGE
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YeSQL
4
+ module Errors
5
+ module FilePathDoesNotExistError
6
+ def validate_file_path_existence(file_path)
7
+ return if file_exists?(file_path)
8
+
9
+ raise NotImplementedError, format(MESSAGE, available_files: available_files,
10
+ file_path: file_path, path: ::YeSQL.config.path)
11
+ end
12
+
13
+ private
14
+
15
+ MESSAGE = <<~MSG
16
+
17
+ SQL file "%<file_path>s" does not exist in %<path>s.
18
+
19
+ Available SQL files are:
20
+
21
+ %<available_files>s
22
+ MSG
23
+ private_constant :MESSAGE
24
+
25
+ def file_exists?(file_path)
26
+ path_files.any? { |filename| filename.include?("#{file_path}.sql") }
27
+ end
28
+
29
+ def available_files
30
+ path_files.map { |file| "- #{file}\n" }.join
31
+ end
32
+
33
+ def path_files
34
+ @path_files ||= Dir["#{::YeSQL.config.path}/**/*.sql"]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YeSQL
4
+ module Errors
5
+ module NoBindingsProvidedError
6
+ def validate_statement_bindings(binds, file_path)
7
+ return unless statement_binds(file_path).size.positive?
8
+
9
+ format(MESSAGE, renderable_statement_binds(file_path)).tap do |message|
10
+ raise ArgumentError, message unless binds.is_a?(Hash) && !binds.empty?
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ MESSAGE = <<~MSG
17
+
18
+ YeSQL invoked without bindings.
19
+
20
+ Expected bindings are:
21
+
22
+ %s
23
+ MSG
24
+ private_constant :MESSAGE
25
+
26
+ def statement_binds(file_path)
27
+ ::YeSQL::Utils::Read.statement(file_path)
28
+ .scan(::YeSQL::BIND_REGEX).tap do |scanned_binds|
29
+ break [] if scanned_binds.size.zero?
30
+
31
+ break scanned_binds.sort
32
+ end
33
+ end
34
+
35
+ def renderable_statement_binds(file_path)
36
+ statement_binds(file_path).flatten.map { |bind| "- `#{bind}`\n" }.join
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YeSQL
4
+ module Errors
5
+ module OutputArgumentError
6
+ def validate_output_options(output)
7
+ return if output.nil?
8
+
9
+ raise ArgumentError, format(MESSAGE, output) unless OPTIONS.include?(output.to_sym)
10
+ end
11
+
12
+ MESSAGE = <<~MSG
13
+ Unsupported `output` option given `%s`. Possible values are:
14
+ - `columns`: returns an array with the columns from the result.
15
+ - `hash`: returns an array of hashes combining both, the columns and rows from the statement result.
16
+ - `rows`: returns an array of arrays for each row from the given SQL statement.
17
+ MSG
18
+ OPTIONS = %i[columns hash rows].freeze
19
+ private_constant :MESSAGE, :OPTIONS
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yesql'
4
+ require 'forwardable'
5
+ require 'yesql/bindings/utils'
6
+ require 'yesql/common/adapter'
7
+
8
+ module YeSQL
9
+ module Query
10
+ class Performer
11
+ extend Forwardable
12
+
13
+ include ::YeSQL::Bindings::Utils
14
+ include ::YeSQL::Common::Adapter
15
+
16
+ # rubocop:disable Metrics/ParameterLists
17
+ def initialize(bind_statement:,
18
+ bindings: {},
19
+ cache: {},
20
+ file_path:,
21
+ output: :rows,
22
+ prepare: false)
23
+ @bind_statement = bind_statement
24
+ @cache = cache
25
+ @cache_key = cache[:key] || file_path
26
+ @connection = ActiveRecord::Base.connection
27
+ @expires_in = cache[:expires_in]
28
+ @file_path = file_path
29
+ @named_bindings = bindings.transform_keys(&:to_sym)
30
+ @output = output
31
+ @prepare = prepare
32
+ end
33
+ # rubocop:enable Metrics/ParameterLists
34
+
35
+ def call
36
+ return modified_output if cache.empty?
37
+
38
+ Rails.cache.fetch(cache_key, expires_in: expires_in) { modified_output }
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :bind_statement,
44
+ :cache,
45
+ :cache_key,
46
+ :connection,
47
+ :expires_in,
48
+ :file_path,
49
+ :named_bindings,
50
+ :output,
51
+ :prepare,
52
+ :rows
53
+
54
+ def_delegator(:query_result, :columns)
55
+ private :columns
56
+ def_delegator(:query_result, :rows)
57
+ private :rows
58
+
59
+ def modified_output
60
+ @modified_output ||=
61
+ begin
62
+ return query_result.public_send(output) if %w[columns rows].include?(output.to_s)
63
+
64
+ columns.map(&:to_sym).tap { |cols| break rows.map { |row| cols.zip(row).to_h } }
65
+ end
66
+ end
67
+
68
+ def query_result
69
+ @query_result ||= connection.exec_query(bind_statement, file_path, binds, prepare: prepare)
70
+ end
71
+
72
+ def extractor
73
+ ::YeSQL::Bindings::Extractor.new(bindings: named_bindings).call
74
+ end
75
+
76
+ # TODO: move this somewhere else.
77
+ def binds
78
+ statement_binds(extractor)
79
+ .sort_by { |_, position| position.to_s.tr('$', '').to_i }
80
+ .tap { |x| break(mysql? ? x : x.uniq) }
81
+ .map(&:first)
82
+ .flatten
83
+ .each_slice(2)
84
+ .to_a
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YeSQL
4
+ module Utils
5
+ module Read
6
+ def self.statement(file_path, readable: false)
7
+ Dir["./#{::YeSQL.config.path}/**/*.sql"]
8
+ .find { |dir_file_path| dir_file_path.include?("#{file_path}.sql") }
9
+ .tap do |sql_file_path|
10
+ break File.readlines(sql_file_path, chomp: true).join(' ') if readable == true
11
+
12
+ break File.read(sql_file_path)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,3 +1,5 @@
1
- module Yesql
2
- VERSION = "0.1.0"
1
+ # frozen_string_literal: true
2
+
3
+ module YeSQL
4
+ VERSION = '0.1.5'
3
5
  end
@@ -1,27 +1,28 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'lib/yesql/version'
2
4
 
3
5
  Gem::Specification.new do |spec|
4
- spec.name = "yesql"
5
- spec.version = Yesql::VERSION
6
- spec.authors = ["Sebastián Palma"]
7
- spec.email = ["vnhnhm.github@gmail.com"]
8
-
9
- spec.summary = "krisajenkins/yesql like Ruby gem"
10
- spec.description = "SQL 'raw' for Rails projects"
11
- spec.homepage = "https://github.com/sebastian-palma/yesql"
12
- spec.license = "MIT"
13
- spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
14
-
15
- spec.metadata["homepage_uri"] = spec.homepage
16
- spec.metadata["source_code_uri"] = "https://github.com/sebastian-palma/yesql"
17
- spec.metadata["changelog_uri"] = "https://github.com/sebastian-palma/yesql"
18
-
19
- # Specify which files should be added to the gem when it is released.
20
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
21
- spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
6
+ spec.name = 'yesql'
7
+ spec.version = YeSQL::VERSION
8
+ spec.authors = ['Sebastián Palma']
9
+ spec.email = ['vnhnhm.github@gmail.com']
10
+ spec.summary = 'Ruby library to use SQL'
11
+ spec.description = 'SQL "raw" for Rails projects'
12
+ spec.homepage = 'https://github.com/sebastian-palma/yesql'
13
+ spec.license = 'MIT'
14
+ spec.metadata['homepage_uri'] = spec.homepage
15
+ spec.metadata['source_code_uri'] = 'https://github.com/sebastian-palma/yesql'
16
+ spec.metadata['changelog_uri'] = 'https://github.com/sebastian-palma/yesql'
17
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
22
18
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
23
19
  end
24
- spec.bindir = "exe"
25
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
- spec.require_paths = ["lib"]
20
+ spec.bindir = 'exe'
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ['lib']
23
+ spec.add_dependency 'rails', '>= 5.0'
24
+ spec.add_development_dependency 'mysql2', '~> 0.5.3'
25
+ spec.add_development_dependency 'pg', '>= 0.18'
26
+ spec.add_development_dependency 'pry', '~> 0.13.1'
27
+ spec.add_development_dependency 'rspec', '~> 3.9.0'
27
28
  end
metadata CHANGED
@@ -1,16 +1,86 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yesql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastián Palma
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-09-11 00:00:00.000000000 Z
12
- dependencies: []
13
- description: SQL 'raw' for Rails projects
11
+ date: 2020-10-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mysql2
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.5.3
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.5.3
41
+ - !ruby/object:Gem::Dependency
42
+ name: pg
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0.18'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0.18'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.13.1
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.13.1
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 3.9.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 3.9.0
83
+ description: SQL "raw" for Rails projects
14
84
  email:
15
85
  - vnhnhm.github@gmail.com
16
86
  executables: []
@@ -18,14 +88,26 @@ extensions: []
18
88
  extra_rdoc_files: []
19
89
  files:
20
90
  - ".gitignore"
21
- - CODE_OF_CONDUCT.md
22
91
  - Gemfile
92
+ - Gemfile.lock
23
93
  - LICENSE.txt
24
94
  - README.md
25
95
  - Rakefile
26
96
  - bin/console
27
97
  - bin/setup
28
98
  - lib/yesql.rb
99
+ - lib/yesql/bindings/binder.rb
100
+ - lib/yesql/bindings/extract.rb
101
+ - lib/yesql/bindings/extractor.rb
102
+ - lib/yesql/bindings/utils.rb
103
+ - lib/yesql/common/adapter.rb
104
+ - lib/yesql/config/configuration.rb
105
+ - lib/yesql/errors/cache_expiration_error.rb
106
+ - lib/yesql/errors/file_path_does_not_exist_error.rb
107
+ - lib/yesql/errors/no_bindings_provided_error.rb
108
+ - lib/yesql/errors/output_argument_error.rb
109
+ - lib/yesql/query/performer.rb
110
+ - lib/yesql/utils/read.rb
29
111
  - lib/yesql/version.rb
30
112
  - yesql.gemspec
31
113
  homepage: https://github.com/sebastian-palma/yesql
@@ -35,7 +117,7 @@ metadata:
35
117
  homepage_uri: https://github.com/sebastian-palma/yesql
36
118
  source_code_uri: https://github.com/sebastian-palma/yesql
37
119
  changelog_uri: https://github.com/sebastian-palma/yesql
38
- post_install_message:
120
+ post_install_message:
39
121
  rdoc_options: []
40
122
  require_paths:
41
123
  - lib
@@ -43,7 +125,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
43
125
  requirements:
44
126
  - - ">="
45
127
  - !ruby/object:Gem::Version
46
- version: 2.3.0
128
+ version: '0'
47
129
  required_rubygems_version: !ruby/object:Gem::Requirement
48
130
  requirements:
49
131
  - - ">="
@@ -51,7 +133,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
51
133
  version: '0'
52
134
  requirements: []
53
135
  rubygems_version: 3.1.2
54
- signing_key:
136
+ signing_key:
55
137
  specification_version: 4
56
- summary: krisajenkins/yesql like Ruby gem
138
+ summary: Ruby library to use SQL
57
139
  test_files: []
@@ -1,74 +0,0 @@
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 vnhnhm@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 [https://contributor-covenant.org/version/1/4][version]
72
-
73
- [homepage]: https://contributor-covenant.org
74
- [version]: https://contributor-covenant.org/version/1/4/