yesql 0.1.0 → 0.1.1

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: 5c342b76a655011550ac627d5f117e183723309e22a5ab67dd896278f2e321c9
4
+ data.tar.gz: 7736ced5895925341a027f59d8814d792eebd7c1fa113bea165ad1dd9c4fa5d0
5
5
  SHA512:
6
- metadata.gz: 4ddb51e8b4a1a07355b5b8c58b4245686e9bad9f20b5c2ae63a4310d4de70512ebd2946c965dfd798b4d7dbd51364fc3e1d310aa10b1d10ce20aacf4dd597dfe
7
- data.tar.gz: 3c2db37304bd5894f740278c58fd79e20a93792d6405898c0f63257ee71216f43af9f4fdece369a4ebdc8aea1148e4a39bab78cacae0b429c78e8aa485e96f97
6
+ metadata.gz: 35190a0eaecbb93b945592e91279b0f932207b574d1341d9a1013f568d1854fd0f5e9962b21c27ecb11ded93437fb33724602ca73138f7ad46310be1aba9aa20
7
+ data.tar.gz: 96b1844015c909c4de69214c74a46a8870f675f61b3bb727ac73038cf98a4a37d6b489ea12bcaf8fd861369de20e2bae3b5a2e1d5075a3ba1d693371eecd2820
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,173 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ yesql (0.1.0)
5
+ dry-configurable (~> 0.11.6)
6
+ rails (>= 5.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ actioncable (6.0.3.3)
12
+ actionpack (= 6.0.3.3)
13
+ nio4r (~> 2.0)
14
+ websocket-driver (>= 0.6.1)
15
+ actionmailbox (6.0.3.3)
16
+ actionpack (= 6.0.3.3)
17
+ activejob (= 6.0.3.3)
18
+ activerecord (= 6.0.3.3)
19
+ activestorage (= 6.0.3.3)
20
+ activesupport (= 6.0.3.3)
21
+ mail (>= 2.7.1)
22
+ actionmailer (6.0.3.3)
23
+ actionpack (= 6.0.3.3)
24
+ actionview (= 6.0.3.3)
25
+ activejob (= 6.0.3.3)
26
+ mail (~> 2.5, >= 2.5.4)
27
+ rails-dom-testing (~> 2.0)
28
+ actionpack (6.0.3.3)
29
+ actionview (= 6.0.3.3)
30
+ activesupport (= 6.0.3.3)
31
+ rack (~> 2.0, >= 2.0.8)
32
+ rack-test (>= 0.6.3)
33
+ rails-dom-testing (~> 2.0)
34
+ rails-html-sanitizer (~> 1.0, >= 1.2.0)
35
+ actiontext (6.0.3.3)
36
+ actionpack (= 6.0.3.3)
37
+ activerecord (= 6.0.3.3)
38
+ activestorage (= 6.0.3.3)
39
+ activesupport (= 6.0.3.3)
40
+ nokogiri (>= 1.8.5)
41
+ actionview (6.0.3.3)
42
+ activesupport (= 6.0.3.3)
43
+ builder (~> 3.1)
44
+ erubi (~> 1.4)
45
+ rails-dom-testing (~> 2.0)
46
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
47
+ activejob (6.0.3.3)
48
+ activesupport (= 6.0.3.3)
49
+ globalid (>= 0.3.6)
50
+ activemodel (6.0.3.3)
51
+ activesupport (= 6.0.3.3)
52
+ activerecord (6.0.3.3)
53
+ activemodel (= 6.0.3.3)
54
+ activesupport (= 6.0.3.3)
55
+ activestorage (6.0.3.3)
56
+ actionpack (= 6.0.3.3)
57
+ activejob (= 6.0.3.3)
58
+ activerecord (= 6.0.3.3)
59
+ marcel (~> 0.3.1)
60
+ activesupport (6.0.3.3)
61
+ concurrent-ruby (~> 1.0, >= 1.0.2)
62
+ i18n (>= 0.7, < 2)
63
+ minitest (~> 5.1)
64
+ tzinfo (~> 1.1)
65
+ zeitwerk (~> 2.2, >= 2.2.2)
66
+ builder (3.2.4)
67
+ coderay (1.1.3)
68
+ concurrent-ruby (1.1.7)
69
+ crass (1.0.6)
70
+ diff-lcs (1.4.4)
71
+ dry-configurable (0.11.6)
72
+ concurrent-ruby (~> 1.0)
73
+ dry-core (~> 0.4, >= 0.4.7)
74
+ dry-equalizer (~> 0.2)
75
+ dry-core (0.4.9)
76
+ concurrent-ruby (~> 1.0)
77
+ dry-equalizer (0.3.0)
78
+ erubi (1.9.0)
79
+ globalid (0.4.2)
80
+ activesupport (>= 4.2.0)
81
+ i18n (1.8.5)
82
+ concurrent-ruby (~> 1.0)
83
+ loofah (2.7.0)
84
+ crass (~> 1.0.2)
85
+ nokogiri (>= 1.5.9)
86
+ mail (2.7.1)
87
+ mini_mime (>= 0.1.1)
88
+ marcel (0.3.3)
89
+ mimemagic (~> 0.3.2)
90
+ method_source (1.0.0)
91
+ mimemagic (0.3.5)
92
+ mini_mime (1.0.2)
93
+ mini_portile2 (2.4.0)
94
+ minitest (5.14.2)
95
+ nio4r (2.5.3)
96
+ nokogiri (1.10.10)
97
+ mini_portile2 (~> 2.4.0)
98
+ pg (1.2.3)
99
+ pry (0.13.1)
100
+ coderay (~> 1.1)
101
+ method_source (~> 1.0)
102
+ rack (2.2.3)
103
+ rack-test (1.1.0)
104
+ rack (>= 1.0, < 3)
105
+ rails (6.0.3.3)
106
+ actioncable (= 6.0.3.3)
107
+ actionmailbox (= 6.0.3.3)
108
+ actionmailer (= 6.0.3.3)
109
+ actionpack (= 6.0.3.3)
110
+ actiontext (= 6.0.3.3)
111
+ actionview (= 6.0.3.3)
112
+ activejob (= 6.0.3.3)
113
+ activemodel (= 6.0.3.3)
114
+ activerecord (= 6.0.3.3)
115
+ activestorage (= 6.0.3.3)
116
+ activesupport (= 6.0.3.3)
117
+ bundler (>= 1.3.0)
118
+ railties (= 6.0.3.3)
119
+ sprockets-rails (>= 2.0.0)
120
+ rails-dom-testing (2.0.3)
121
+ activesupport (>= 4.2.0)
122
+ nokogiri (>= 1.6)
123
+ rails-html-sanitizer (1.3.0)
124
+ loofah (~> 2.3)
125
+ railties (6.0.3.3)
126
+ actionpack (= 6.0.3.3)
127
+ activesupport (= 6.0.3.3)
128
+ method_source
129
+ rake (>= 0.8.7)
130
+ thor (>= 0.20.3, < 2.0)
131
+ rake (13.0.1)
132
+ rspec (3.9.0)
133
+ rspec-core (~> 3.9.0)
134
+ rspec-expectations (~> 3.9.0)
135
+ rspec-mocks (~> 3.9.0)
136
+ rspec-core (3.9.2)
137
+ rspec-support (~> 3.9.3)
138
+ rspec-expectations (3.9.2)
139
+ diff-lcs (>= 1.2.0, < 2.0)
140
+ rspec-support (~> 3.9.0)
141
+ rspec-mocks (3.9.1)
142
+ diff-lcs (>= 1.2.0, < 2.0)
143
+ rspec-support (~> 3.9.0)
144
+ rspec-support (3.9.3)
145
+ sprockets (4.0.2)
146
+ concurrent-ruby (~> 1.0)
147
+ rack (> 1, < 3)
148
+ sprockets-rails (3.2.2)
149
+ actionpack (>= 4.0)
150
+ activesupport (>= 4.0)
151
+ sprockets (>= 3.0.0)
152
+ thor (1.0.1)
153
+ thread_safe (0.3.6)
154
+ tzinfo (1.2.7)
155
+ thread_safe (~> 0.1)
156
+ websocket-driver (0.7.3)
157
+ websocket-extensions (>= 0.1.0)
158
+ websocket-extensions (0.1.5)
159
+ yard (0.9.25)
160
+ zeitwerk (2.4.0)
161
+
162
+ PLATFORMS
163
+ ruby
164
+
165
+ DEPENDENCIES
166
+ pg (>= 0.18)
167
+ pry (~> 0.13.1)
168
+ rspec (~> 3.9.0)
169
+ yard
170
+ yesql!
171
+
172
+ BUNDLED WITH
173
+ 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,155 @@ 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
+ TODO: update this with link to the error.
74
+ - If the query has bindings, but nothing is provided, it raises a `NotImplementedError` exception.
75
+
76
+
77
+ #### `output`
78
+
79
+
80
+ If you need an output other than an array of arrays, you can use the `output` options. It accepts three values:
81
+
82
+ - `:columns` (or `'columns'`): returns an array containing the name of the columns returned from the query in the format `['column_a', 'column_b', ...]`.
83
+ - `:hash` (or `'hash'`): returns an array of hashes in the format `[{ column: value }, ...]`.
84
+ - `:rows` (or `'rows'`) DEFAULT: returns an array of arrays containing the result values from the query in the format `[[value1, value2, ...], ...]`.
85
+
86
+ Example:
87
+
88
+ ```ruby
89
+ YeSQL('users', output: :columns)
90
+ # => ['id', 'name', 'admin', 'created_at', 'updated_at']
91
+
92
+ YeSQL('users', output: :hash)
93
+ # => [{: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}]
94
+
95
+ YeSQL('users', output: :rows)
96
+ # => [[1, nil, nil, 2020-09-27 21:27:02.997839 UTC, 2020-09-27 21:27:02.997839 UTC]]
97
+
98
+ YeSQL('users') # same as in `YeSQL('users', output: :rows)`
99
+ # => [[1, nil, nil, 2020-09-27 21:27:02.997839 UTC, 2020-09-27 21:27:02.997839 UTC]]
100
+ ```
101
+
102
+ - If an unsupported `output` value is provided it raises a `NotImplementedError` exception.
103
+ - If no `output` value is provided, the default is `rows`.
28
104
 
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
105
 
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).
106
+ #### `prepare`
107
+
108
+ Using `prepare: true` it creates a prepared statement with the content of the SQL file:
109
+
110
+ ```ruby
111
+ ActiveRecord::Base.connection.execute('SELECT * FROM pg_prepared_statements').to_a
112
+ (0.Xms) SELECT * FROM pg_prepared_statements
113
+ # => []
114
+
115
+ YeSQL('top_10_users_in_x_country', prepare: true)
116
+ # ...
117
+
118
+ ActiveRecord::Base.connection.execute('SELECT * FROM pg_prepared_statements').to_a
119
+ (0.Xms) SELECT * FROM pg_prepared_statements
120
+ # => [{"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}]
121
+ ```
122
+
123
+
124
+ #### `cache`
125
+
126
+ 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:
127
+
128
+ ```ruby
129
+ YeSQL('users', cache: { key: 'users', expires_in: 1.hour })
130
+ ```
131
+
132
+ That's enough to cache the result of the query for 1 hour with the cache key "users".
133
+
134
+ If no `key` key/value is used, then the cache key is the name of the file containing the SQL code, and
135
+
136
+ ```ruby
137
+ YeSQL('users', cache: { key: 'users', expires_in: 1.hour })
138
+ ```
139
+
140
+ is the same as
141
+
142
+ ```ruby
143
+ YeSQL('users', cache: { expires_in: 1.hour })
144
+ ```
145
+
146
+ ## Configuration
147
+
148
+ 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/` folder as:
149
+
150
+ ```ruby
151
+ YeSQL.config.path = 'path'
152
+ ```
153
+
154
+ After saving the file and restarting the server the files are going to be read from the given folder.
155
+
156
+ You can check at anytime what's the configuration path by inspecting the YeSQL `config` object:
157
+
158
+ ```ruby
159
+ YeSQL.config
160
+ # <Dry::Configurable::Config values={:path=>"path"}>
161
+ Yesql.config.path
162
+ # "path"
163
+ ```
164
+
165
+
166
+ ## Development
167
+
168
+ - Clone the repository.
169
+ - Install the gem dependencies.
170
+ - Make sure to create the database used in the dummy Rails application in the spec/ folder.
171
+ - Run the tests.
32
172
 
33
173
  ## Contributing
34
174
 
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).
175
+ 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
176
 
37
177
 
38
178
  ## License
@@ -41,4 +181,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
41
181
 
42
182
  ## Code of Conduct
43
183
 
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).
184
+ 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,54 @@
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 'dry-configurable'
4
+ require 'pry'
5
+ require 'yesql/version'
6
+ require 'yesql/query/performer'
7
+ require 'yesql/errors/cache_expiration_error'
8
+ require 'yesql/errors/file_path_does_not_exist_error'
9
+ require 'yesql/errors/no_bindings_provided_error'
10
+ require 'yesql/errors/output_argument_error'
11
+ require 'yesql/bindings/binder'
12
+
13
+ module YeSQL
14
+ extend ::Dry::Configurable
15
+
16
+ include ::YeSQL::Errors::CacheExpirationError
17
+ include ::YeSQL::Errors::FilePathDoesNotExistError
18
+ include ::YeSQL::Errors::NoBindingsProvidedError
19
+ include ::YeSQL::Errors::OutputArgumentError
20
+
21
+ setting :path, 'app/yesql'
22
+
23
+ BIND_REGEX = /(?<!:):(\w+)(?=\b)/.freeze
24
+
25
+ # rubocop:disable Naming/MethodName
26
+ def YeSQL(file_path, bindings = {}, options = {})
27
+ output = options[:output] || :rows
28
+ cache = options[:cache] || {}
29
+
30
+ validate(bindings, cache, file_path, output)
31
+ execute(bindings, cache, file_path, output, options)
32
+ end
33
+ # rubocop:enable Naming/MethodName
34
+
35
+ private
36
+
37
+ def validate(bindings, cache, file_path, output)
38
+ validate_file_path_existence(file_path)
39
+ validate_statement_bindings(bindings, file_path)
40
+ validate_output_options(output)
41
+ validate_cache_expiration(cache[:expires_in]) unless cache.empty?
42
+ end
43
+
44
+ def execute(bindings, cache, file_path, output, options)
45
+ ::YeSQL::Query::Performer.new(
46
+ bindings: bindings,
47
+ bind_statement: ::YeSQL::Bindings::Binder.bind_statement(file_path, bindings),
48
+ cache: cache,
49
+ file_path: file_path,
50
+ output: output,
51
+ prepare: options[:prepare]
52
+ ).call
53
+ end
6
54
  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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yesql'
4
+
5
+ module YeSQL
6
+ module Bindings
7
+ class Extract
8
+ def initialize(indexed_bindings, hash, index, value)
9
+ @indexed_bindings = indexed_bindings
10
+ @hash = hash
11
+ @index = index
12
+ @value = value
13
+ end
14
+
15
+ def bind_vals
16
+ return [nil, value] unless array?
17
+
18
+ value.map { |bind| [nil, bind] }
19
+ end
20
+
21
+ def bind_vars
22
+ return "$#{last_val}" unless array?
23
+
24
+ value.map.with_index(bind_index) { |_, i| "$#{i}" }.join(', ')
25
+ end
26
+
27
+ def last_val
28
+ prev_last_val + current_val_size
29
+ end
30
+
31
+ def prev
32
+ return if first?
33
+
34
+ indexed_bindings[index - 2].first
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :hash, :index, :indexed_bindings, :value
40
+
41
+ def current_val_size
42
+ return value.size if array?
43
+
44
+ 1
45
+ end
46
+
47
+ def prev_last_val
48
+ return 0 if first?
49
+
50
+ hash[prev][:last_val]
51
+ end
52
+
53
+ def first?
54
+ index == 1
55
+ end
56
+
57
+ def array?
58
+ value.is_a?(Array)
59
+ end
60
+
61
+ def bind_index
62
+ return 1 if first?
63
+
64
+ prev_last_val + 1
65
+ end
66
+ end
67
+ end
68
+ 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,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,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yesql'
4
+ require 'forwardable'
5
+ require 'yesql/bindings/utils'
6
+
7
+ module YeSQL
8
+ module Query
9
+ class Performer
10
+ extend Forwardable
11
+
12
+ include ::YeSQL::Bindings::Utils
13
+
14
+ # rubocop:disable Metrics/ParameterLists
15
+ def initialize(bind_statement:,
16
+ bindings: {},
17
+ cache: {},
18
+ file_path:,
19
+ output: :rows,
20
+ prepare: false)
21
+ @bind_statement = bind_statement
22
+ @cache = cache
23
+ @cache_key = cache[:key] || file_path
24
+ @connection = ActiveRecord::Base.connection
25
+ @expires_in = cache[:expires_in]
26
+ @file_path = file_path
27
+ @named_bindings = bindings.transform_keys(&:to_sym)
28
+ @output = output
29
+ @prepare = prepare
30
+ end
31
+ # rubocop:enable Metrics/ParameterLists
32
+
33
+ def call
34
+ return modified_output if cache.empty?
35
+
36
+ Rails.cache.fetch(cache_key, expires_in: expires_in) { modified_output }
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :bind_statement,
42
+ :cache,
43
+ :cache_key,
44
+ :connection,
45
+ :expires_in,
46
+ :file_path,
47
+ :named_bindings,
48
+ :output,
49
+ :prepare,
50
+ :rows
51
+
52
+ def_delegator(:query_result, :columns)
53
+ private :columns
54
+ def_delegator(:query_result, :rows)
55
+ private :rows
56
+
57
+ def modified_output
58
+ @modified_output ||=
59
+ begin
60
+ return query_result.public_send(output) if %w[columns rows].include?(output.to_s)
61
+
62
+ columns.map(&:to_sym).tap { |cols| break rows.map { |row| cols.zip(row).to_h } }
63
+ end
64
+ end
65
+
66
+ def query_result
67
+ @query_result ||= connection.exec_query(bind_statement, file_path, binds, prepare: prepare)
68
+ end
69
+
70
+ def binds
71
+ ::YeSQL::Bindings::Extractor.new(bindings: named_bindings).call.tap do |extractor|
72
+ break statement_binds(extractor).sort_by(&:last)
73
+ .map(&:first)
74
+ .flatten
75
+ .each_slice(2)
76
+ .to_a
77
+ end
78
+ end
79
+ end
80
+ end
81
+ 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.1'
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 'dry-configurable', '~> 0.11.6'
24
+ spec.add_dependency 'rails', '>= 5.0'
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,31 +1,110 @@
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastián Palma
8
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-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-configurable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.11.6
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.11.6
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
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: []
17
87
  extensions: []
18
88
  extra_rdoc_files: []
19
89
  files:
20
- - ".gitignore"
21
- - CODE_OF_CONDUCT.md
22
90
  - Gemfile
91
+ - Gemfile.lock
23
92
  - LICENSE.txt
24
93
  - README.md
25
94
  - Rakefile
26
95
  - bin/console
27
96
  - bin/setup
28
97
  - lib/yesql.rb
98
+ - lib/yesql/bindings/binder.rb
99
+ - lib/yesql/bindings/extract.rb
100
+ - lib/yesql/bindings/extractor.rb
101
+ - lib/yesql/bindings/utils.rb
102
+ - lib/yesql/errors/cache_expiration_error.rb
103
+ - lib/yesql/errors/file_path_does_not_exist_error.rb
104
+ - lib/yesql/errors/no_bindings_provided_error.rb
105
+ - lib/yesql/errors/output_argument_error.rb
106
+ - lib/yesql/query/performer.rb
107
+ - lib/yesql/utils/read.rb
29
108
  - lib/yesql/version.rb
30
109
  - yesql.gemspec
31
110
  homepage: https://github.com/sebastian-palma/yesql
@@ -43,7 +122,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
43
122
  requirements:
44
123
  - - ">="
45
124
  - !ruby/object:Gem::Version
46
- version: 2.3.0
125
+ version: '0'
47
126
  required_rubygems_version: !ruby/object:Gem::Requirement
48
127
  requirements:
49
128
  - - ">="
@@ -53,5 +132,5 @@ requirements: []
53
132
  rubygems_version: 3.1.2
54
133
  signing_key:
55
134
  specification_version: 4
56
- summary: krisajenkins/yesql like Ruby gem
135
+ summary: Ruby library to use SQL
57
136
  test_files: []
data/.gitignore DELETED
@@ -1 +0,0 @@
1
- *.gem
@@ -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/