ensql 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +21 -0
- data/Gemfile.lock +69 -0
- data/LICENSE.txt +21 -0
- data/README.md +188 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/rspec +29 -0
- data/bin/setup +8 -0
- data/ensql.gemspec +32 -0
- data/lib/ensql.rb +109 -0
- data/lib/ensql/active_record_adapter.rb +60 -0
- data/lib/ensql/adapter.rb +87 -0
- data/lib/ensql/sequel_adapter.rb +56 -0
- data/lib/ensql/sql.rb +161 -0
- data/lib/ensql/version.rb +10 -0
- metadata +64 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 3097f158523b78a965c84dbf0e79f41ce4be744d5dd0842fc9f5f780ec9b5fba
|
4
|
+
data.tar.gz: e5a123a39ab5f6c1b1911bf46972e441f6bb77d69584fd7bb005192baa5a24c8
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 02a9e0a720bc3b861e27782233fac13ec9e58cfb4b3a6892b2d224f3a0d1de76051f2300370ded32fd9acfe677b71705510e3ee1dd95085cbb7b1fdfd8dda676
|
7
|
+
data.tar.gz: 724701d41daf5554702c6173a2aa6e063034ca3d094ed29eb4cf760c6aba467a13a623f4e5fbc904ca95418a111e281cca4104eb55e8b04ce8d3673323b2d77b
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.7.2
|
data/.yardopts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--markup markdown
|
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source "https://rubygems.org"
|
4
|
+
|
5
|
+
# Specify your gem's dependencies in ensql.gemspec
|
6
|
+
gemspec
|
7
|
+
|
8
|
+
gem "rake", "~> 13.0"
|
9
|
+
|
10
|
+
gem "rspec", "~> 3.0"
|
11
|
+
# Ensure test coverage
|
12
|
+
gem "simplecov", "~> 0.21.2"
|
13
|
+
|
14
|
+
# Database adapters
|
15
|
+
require_relative 'lib/ensql/version'
|
16
|
+
gem "activerecord", ENV['ACTIVERECORD_VERSION'] || Ensql::ACTIVERECORD_VERSION
|
17
|
+
gem "sequel", ENV['SEQUEL_VERSION'] || Ensql::SEQUEL_VERSION
|
18
|
+
gem "sqlite3", ENV['SQLITE3_VERSION'] || "~> 1.4"
|
19
|
+
gem "pg", ENV['PG_VERSION'] || "~> 1.2"
|
20
|
+
|
21
|
+
gem "yard", "~> 0.9.26"
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
ensql (0.6.0)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: https://rubygems.org/
|
8
|
+
specs:
|
9
|
+
activemodel (6.1.3)
|
10
|
+
activesupport (= 6.1.3)
|
11
|
+
activerecord (6.1.3)
|
12
|
+
activemodel (= 6.1.3)
|
13
|
+
activesupport (= 6.1.3)
|
14
|
+
activesupport (6.1.3)
|
15
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
16
|
+
i18n (>= 1.6, < 2)
|
17
|
+
minitest (>= 5.1)
|
18
|
+
tzinfo (~> 2.0)
|
19
|
+
zeitwerk (~> 2.3)
|
20
|
+
concurrent-ruby (1.1.8)
|
21
|
+
diff-lcs (1.4.4)
|
22
|
+
docile (1.3.5)
|
23
|
+
i18n (1.8.9)
|
24
|
+
concurrent-ruby (~> 1.0)
|
25
|
+
minitest (5.14.3)
|
26
|
+
pg (1.2.3)
|
27
|
+
rake (13.0.3)
|
28
|
+
rspec (3.10.0)
|
29
|
+
rspec-core (~> 3.10.0)
|
30
|
+
rspec-expectations (~> 3.10.0)
|
31
|
+
rspec-mocks (~> 3.10.0)
|
32
|
+
rspec-core (3.10.1)
|
33
|
+
rspec-support (~> 3.10.0)
|
34
|
+
rspec-expectations (3.10.1)
|
35
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
36
|
+
rspec-support (~> 3.10.0)
|
37
|
+
rspec-mocks (3.10.2)
|
38
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
39
|
+
rspec-support (~> 3.10.0)
|
40
|
+
rspec-support (3.10.2)
|
41
|
+
sequel (5.41.0)
|
42
|
+
simplecov (0.21.2)
|
43
|
+
docile (~> 1.1)
|
44
|
+
simplecov-html (~> 0.11)
|
45
|
+
simplecov_json_formatter (~> 0.1)
|
46
|
+
simplecov-html (0.12.3)
|
47
|
+
simplecov_json_formatter (0.1.2)
|
48
|
+
sqlite3 (1.4.2)
|
49
|
+
tzinfo (2.0.4)
|
50
|
+
concurrent-ruby (~> 1.0)
|
51
|
+
yard (0.9.26)
|
52
|
+
zeitwerk (2.4.2)
|
53
|
+
|
54
|
+
PLATFORMS
|
55
|
+
x86_64-darwin-18
|
56
|
+
|
57
|
+
DEPENDENCIES
|
58
|
+
activerecord (>= 5.0, < 6.2)
|
59
|
+
ensql!
|
60
|
+
pg (~> 1.2)
|
61
|
+
rake (~> 13.0)
|
62
|
+
rspec (~> 3.0)
|
63
|
+
sequel (~> 5.10)
|
64
|
+
simplecov (~> 0.21.2)
|
65
|
+
sqlite3 (~> 1.4)
|
66
|
+
yard (~> 0.9.26)
|
67
|
+
|
68
|
+
BUNDLED WITH
|
69
|
+
2.2.9
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2021 Daniel Fone
|
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,188 @@
|
|
1
|
+
# Ensql
|
2
|
+
|
3
|
+
Ensql lets you write SQL for your application the safe and simple way. Ditch your ORM and embrace the power and
|
4
|
+
simplicity of writing plain SQL again.
|
5
|
+
|
6
|
+
* **Write exactly the SQL you want.** Don't limit your queries to what's in the Rails docs. Composable scopes and
|
7
|
+
dynamic includes can cripple performance for non-trivial queries. Break through the ORM abstraction and unlock the
|
8
|
+
power of your database with well-structured SQL and modern database features.
|
9
|
+
|
10
|
+
* **Keep your SQL in its own files.** Just like models or view templates, it makes sense to organise your SQL on its
|
11
|
+
own terms. Storing the queries in their own files encourages better formatted, well commented, literate SQL. It also
|
12
|
+
leverages the syntax highlighting and autocompletion available in your editor. Snippets of HTML scatter through .rb
|
13
|
+
files is an awkward code smell, and SQL is no different.
|
14
|
+
|
15
|
+
* **Do more with your database.** Having a place to organise clean and readable SQL encourages you to make the most of it.
|
16
|
+
In every project I've worked on I've been able to replace useful amounts of imperative ruby logic with a declarative
|
17
|
+
SQL query, improving performance and reducing the opportunity for type errors and untested branches.
|
18
|
+
|
19
|
+
* **Safely interpolate user-supplied data.** Every web developer knows the risks of SQL injection. Ensql takes a
|
20
|
+
fail-safe approach to interpolation, leveraging the underlying database adapter to turn ruby objects into properly
|
21
|
+
quoted SQL literals. As long as user-supplied input is passed as parameters, your queries will be safe and
|
22
|
+
well-formed.
|
23
|
+
|
24
|
+
* **Use your existing database connection.** Ensql works with ActiveRecord or Sequel so you don't need to manage a
|
25
|
+
separate connection to the database.
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
# Run adhoc statements
|
29
|
+
Ensql.run("SET TIME ZONE 'UTC'")
|
30
|
+
|
31
|
+
# Run adhoc D/U/I statements and get the affected row count
|
32
|
+
Ensql.sql('DELETE FROM logs WHERE timestamp < %{expiry}', expiry: 1.month.ago).count # => 100
|
33
|
+
|
34
|
+
# Organise your SQL and fetch results as convenient Ruby primitives
|
35
|
+
Ensql.sql_path = 'app/sql'
|
36
|
+
Ensql.load_sql('customers/revenue_report', params).rows # => [{ "customer_id" => 100, "revenue" => 1000}, … ]
|
37
|
+
|
38
|
+
# Easily retrive results in the simplest shape
|
39
|
+
Ensql.sql('SELECT count(*) FROM users').first_field # => 100
|
40
|
+
Ensql.sql('SELECT id FROM users').first_column # => [1, 2, 3, …]
|
41
|
+
Ensql.sql('SELECT * FROM users WHERE id = %{id}', id: 1).first_row # => { "id" => 1, "email" => "test@example.com" }
|
42
|
+
|
43
|
+
# Compose multiple queries with fragment interpolation
|
44
|
+
all_results = Ensql.load_sql('results/all', user_id: user.id)
|
45
|
+
current_results = Ensql.load_sql('results/page', results: all_results, page: 2)
|
46
|
+
total = Ensql.load_sql('count', subquery: all_results)
|
47
|
+
result = { data: current_results.rows, total: total.first_field }
|
48
|
+
```
|
49
|
+
|
50
|
+
## Installation
|
51
|
+
|
52
|
+
Add this gem to your Gemfile by running:
|
53
|
+
|
54
|
+
$ bundle add ensql
|
55
|
+
|
56
|
+
Or install it manually with:
|
57
|
+
|
58
|
+
$ gem install ensql
|
59
|
+
|
60
|
+
## Usage
|
61
|
+
|
62
|
+
Typically, you don't need to configure anything. Ensql will look for Sequel or ActiveRecord (in that order) and load the
|
63
|
+
appropriate adapter. You can override this if you need to, or configure your own adapter. See {Ensql::Adapter} for
|
64
|
+
details of the interface.
|
65
|
+
|
66
|
+
```ruby
|
67
|
+
Ensql.adapter = Ensql::ActiveRecordAdapter # Will use ActiveRecord instead
|
68
|
+
```
|
69
|
+
|
70
|
+
SQL can be supplied directly or read from a file. You're encouraged to organise all but the most trivial statements in
|
71
|
+
their own *.sql files, for the reasons outlined above. You can organise them in whatever way makes most sense for your
|
72
|
+
project, but I've found sorting them into directories based on their purpose works well. For example:
|
73
|
+
|
74
|
+
app/sql
|
75
|
+
├── analytics
|
76
|
+
│ └── results.sql
|
77
|
+
├── program_details
|
78
|
+
│ ├── widget_query.sql
|
79
|
+
│ ├── item_query.sql
|
80
|
+
│ ├── organisation_query.sql
|
81
|
+
│ └── test_query.sql
|
82
|
+
├── reports
|
83
|
+
│ ├── csv_export.sql
|
84
|
+
│ ├── filtered.sql
|
85
|
+
│ └── index.sql
|
86
|
+
├── redaction.sql
|
87
|
+
├── count.sql
|
88
|
+
└── set_timeout.sql
|
89
|
+
|
90
|
+
### Interpolation
|
91
|
+
|
92
|
+
All interpolation is marked by `%{}` placeholders in the SQL. This is the only place that user-supplied input should be
|
93
|
+
allowed. Only various forms of literal interpolation are supported - identifier interpolation is not supported at this
|
94
|
+
stage.
|
95
|
+
|
96
|
+
There are 4 types of interpolation, see {Ensql::SQL} for details.
|
97
|
+
|
98
|
+
1. `%{param}` interpolates a Ruby object as a SQL literal.
|
99
|
+
2. `%{(param)}` expands an array into a list of SQL literals.
|
100
|
+
3. `%{param( nested sql )}` interpolates the nested sql with each hash in an array.
|
101
|
+
4. `%{!sql_param}` only interpolates Ensql::SQL objects as SQL fragments.
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
# Interpolate a literal
|
105
|
+
Ensql.sql('SELECT * FROM users WHERE email > %{date}', date: Date.today)
|
106
|
+
# SELECT * FROM users WHERE email > '2021-02-22'
|
107
|
+
|
108
|
+
# Interpolate a list
|
109
|
+
Ensql.sql('SELECT * FROM users WHERE name IN %{(names)}', names: ['user1', 'user2'])
|
110
|
+
# SELECT * FROM users WHERE name IN ('user1', 'user2')
|
111
|
+
|
112
|
+
# Interpolate a nested VALUES list
|
113
|
+
Ensql.sql('INSERT INTO users (name, created_at) VALUES %{users( %{name}, now() )}',
|
114
|
+
users: [{ name: "Claudia Buss" }, { name: "Lundy L'Anglais" }]
|
115
|
+
)
|
116
|
+
# INSERT INTO users VALUES ('Claudia Buss', now()), ('Lundy L''Anglais', now())
|
117
|
+
|
118
|
+
# Interpolate a SQL fragement
|
119
|
+
Ensql.sql('SELECT * FROM users ORDER BY %{!orderby}', orderby: Ensql.sql('name asc'))
|
120
|
+
# SELECT * FROM users ORDER BY name asc
|
121
|
+
```
|
122
|
+
|
123
|
+
Interpolation occurs just before the SQL is executed.
|
124
|
+
|
125
|
+
### Results
|
126
|
+
|
127
|
+
The result of an SQL query will always be a table of rows and columns, and most of the time this is what we want.
|
128
|
+
However, sometimes our queries only return a single row, column, or value. For ease-of-use, Ensql supports all 4
|
129
|
+
possible access patterns.
|
130
|
+
|
131
|
+
1. Table: an array of rows as hashes
|
132
|
+
2. Row: a hash of the first row
|
133
|
+
3. Column: an array of the first column
|
134
|
+
4. Field: an object of the first field
|
135
|
+
|
136
|
+
```ruby
|
137
|
+
Ensql.sql('SELECT * FROM users').rows # => [{ "id" => 1, …}, {"id" => 2, …}, …]
|
138
|
+
Ensql.sql('SELECT count(*) FROM users').first_field # => 100
|
139
|
+
Ensql.sql('SELECT id FROM users').first_column # => [1, 2, 3, …]
|
140
|
+
Ensql.sql('SELECT * FROM users WHERE id = %{id}', id: 1).first_row # => { "id" => 1, "email" => "test@example.com" }
|
141
|
+
```
|
142
|
+
|
143
|
+
Depending on the database and adapter, the values will be deserialised into Ruby objects.
|
144
|
+
|
145
|
+
```ruby
|
146
|
+
Ensql.sql("SELECT now() AS now, CAST('[1,2,3]' AS json)", id: 1).first_row
|
147
|
+
# => { "now" => 2021-02-23 21:17:28.105537 +1300, "json" => [1, 2, 3] }
|
148
|
+
```
|
149
|
+
|
150
|
+
Additionally, you can just return the number of rows affected, or nothing at all.
|
151
|
+
|
152
|
+
```ruby
|
153
|
+
Ensql.sql('DELETE FROM users WHERE email IS NULL').count # 10
|
154
|
+
Ensql.sql('TRUNCATE logs').run # => nil
|
155
|
+
Ensql.run('TRUNCATE logs') # same thing
|
156
|
+
```
|
157
|
+
|
158
|
+
## Things To Improve
|
159
|
+
|
160
|
+
- Interpolation syntax. I'd love to ground this in something more reasonable than ruby's custom sprintf format. Maybe we
|
161
|
+
could relate it to the standard SQL `?` or chose an existing named bind parameter format.
|
162
|
+
|
163
|
+
- Maybe we could use type hinting like `%{param:pgarray}` to indicated how to serialise the object as a literal.
|
164
|
+
|
165
|
+
- Detecting the database and switching to a db specific adapters. This allows us to be more efficient and optimise some
|
166
|
+
literals in a database specific format, e.g. postgres array literals.
|
167
|
+
|
168
|
+
- Handling specific connections rather than just grabbing the default.
|
169
|
+
|
170
|
+
- Establishing connections directly.
|
171
|
+
|
172
|
+
## Development
|
173
|
+
|
174
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You'll
|
175
|
+
need a running postgres database. You can also run `bin/console` for an interactive prompt that will allow you to
|
176
|
+
experiment.
|
177
|
+
|
178
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the
|
179
|
+
version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version,
|
180
|
+
push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
181
|
+
|
182
|
+
## Contributing
|
183
|
+
|
184
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/danielfone/ensql.
|
185
|
+
|
186
|
+
## License
|
187
|
+
|
188
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "ensql"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require "irb"
|
15
|
+
IRB.start(__FILE__)
|
data/bin/rspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rspec' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require "pathname"
|
12
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
13
|
+
Pathname.new(__FILE__).realpath)
|
14
|
+
|
15
|
+
bundle_binstub = File.expand_path("../bundle", __FILE__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
require "rubygems"
|
27
|
+
require "bundler/setup"
|
28
|
+
|
29
|
+
load Gem.bin_path("rspec-core", "rspec")
|
data/bin/setup
ADDED
data/ensql.gemspec
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/ensql/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "ensql"
|
7
|
+
spec.version = Ensql::VERSION
|
8
|
+
spec.authors = ["Daniel Fone"]
|
9
|
+
spec.email = ["daniel@fone.net.nz"]
|
10
|
+
|
11
|
+
spec.summary = "Write SQL the safe and simple way"
|
12
|
+
spec.description = "Ditch your ORM and embrace the power and simplicity of writing plain SQL again."
|
13
|
+
spec.homepage = "https://github.com/danielfone/ensql"
|
14
|
+
spec.license = "MIT"
|
15
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
|
16
|
+
|
17
|
+
# spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
|
18
|
+
|
19
|
+
# spec.metadata["homepage_uri"] = spec.homepage
|
20
|
+
# spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
|
21
|
+
# spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
|
22
|
+
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
24
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
25
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
26
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
27
|
+
end
|
28
|
+
spec.bindir = "exe"
|
29
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
30
|
+
spec.require_paths = ["lib"]
|
31
|
+
|
32
|
+
end
|
data/lib/ensql.rb
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "ensql/version"
|
4
|
+
require_relative "ensql/sql"
|
5
|
+
|
6
|
+
#
|
7
|
+
# Primary interface for loading, interpolating and executing SQL statements
|
8
|
+
# using your preferred database connection. See {.sql} for interpolation details.
|
9
|
+
#
|
10
|
+
# @example
|
11
|
+
# # Run adhoc statements
|
12
|
+
# Ensql.run("SET TIME ZONE 'UTC'")
|
13
|
+
#
|
14
|
+
# # Run adhoc D/U/I statements and get the affected row count
|
15
|
+
# Ensql.sql('DELETE FROM logs WHERE timestamp < %{expiry}', expiry: 1.month.ago).count # => 100
|
16
|
+
#
|
17
|
+
# # Organise your SQL and fetch results as convenient Ruby primitives
|
18
|
+
# Ensql.sql_path = 'app/sql'
|
19
|
+
# Ensql.load_sql('customers/revenue_report', params).rows # => [{ "customer_id" => 100, "revenue" => 1000}, … ]
|
20
|
+
#
|
21
|
+
# # Easily retrive results in alternative dimensions
|
22
|
+
# Ensql.sql('select count(*) from users').first_field # => 100
|
23
|
+
# Ensql.sql('select id from users').first_column # => [1, 2, 3, …]
|
24
|
+
# Ensql.sql('select * from users where id = %{id}', id: 1).first_row # => { "id" => 1, "email" => "test@example.com" }
|
25
|
+
#
|
26
|
+
module Ensql
|
27
|
+
# Wrapper for errors raised by Ensql
|
28
|
+
class Error < StandardError; end
|
29
|
+
|
30
|
+
class << self
|
31
|
+
|
32
|
+
# (see SQL)
|
33
|
+
# @return [Ensql::SQL] SQL statement
|
34
|
+
def sql(sql, params={})
|
35
|
+
SQL.new(sql, params)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Path to search for *.sql queries in, defaults to "sql/". For example, if
|
39
|
+
# {sql_path} is set to 'app/queries', `load_sql('users/active')` will read
|
40
|
+
# 'app/queries/users/active.sql'.
|
41
|
+
# @see .load_sql
|
42
|
+
#
|
43
|
+
# @example
|
44
|
+
# Ensql.sql_path = Rails.root.join('app/queries')
|
45
|
+
#
|
46
|
+
def sql_path
|
47
|
+
@sql_path ||= 'sql'
|
48
|
+
end
|
49
|
+
attr_writer :sql_path
|
50
|
+
|
51
|
+
# Load SQL from a file within {sql_path}. This is the recommended way to
|
52
|
+
# manage SQL in a non-trivial project. For details of how to write
|
53
|
+
# interpolation placeholders, see {SQL}.
|
54
|
+
#
|
55
|
+
# @see .sql_path=
|
56
|
+
# @return [Ensql::SQL]
|
57
|
+
#
|
58
|
+
# @example
|
59
|
+
# Ensql.load_sql('users/activity', report_params)
|
60
|
+
# Ensql.load_sql(:upsert_users, imported_users_attrs)
|
61
|
+
#
|
62
|
+
def load_sql(name, params={})
|
63
|
+
path = File.join(sql_path, "#{name}.sql")
|
64
|
+
SQL.new(File.read(path), params, name)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Convenience method to interpolate and run the supplied SQL on the current
|
68
|
+
# adapter.
|
69
|
+
# @return [void]
|
70
|
+
#
|
71
|
+
# @example
|
72
|
+
# Ensql.run("DELETE FROM users WHERE id = %{id}", id: user.id)
|
73
|
+
# Ensql.run("ALTER TABLE test RENAME TO old_test")
|
74
|
+
#
|
75
|
+
def run(sql, params={})
|
76
|
+
SQL.new(sql, params).run
|
77
|
+
end
|
78
|
+
|
79
|
+
# Connection adapter to use. Must implement the interface defined in
|
80
|
+
# {Ensql::Adapter}. If not specified, it will try to autoload an adapter
|
81
|
+
# based on the availability of Sequel or ActiveRecord, in that order.
|
82
|
+
#
|
83
|
+
# @example
|
84
|
+
# require 'sequel'
|
85
|
+
# Ensql.adapter # => Ensql::SequelAdapter
|
86
|
+
# Ensql.adapter = Ensql::ActiveRecordAdapter # override adapter
|
87
|
+
# Ensql.adapter = CustomMSSQLAdapater # supply your own adapter
|
88
|
+
#
|
89
|
+
def adapter
|
90
|
+
@adapter ||= autoload_adapter
|
91
|
+
end
|
92
|
+
attr_writer :adapter
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def autoload_adapter
|
97
|
+
if defined? Sequel
|
98
|
+
require_relative 'ensql/sequel_adapter'
|
99
|
+
SequelAdapter
|
100
|
+
elsif defined? ActiveRecord
|
101
|
+
require_relative 'ensql/active_record_adapter'
|
102
|
+
ActiveRecordAdapter
|
103
|
+
else
|
104
|
+
raise Error, "Couldn't autodetect an adapter, please specify manually."
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'version'
|
4
|
+
require_relative 'adapter'
|
5
|
+
|
6
|
+
# Ensure our optional dependency has a compatible version
|
7
|
+
gem 'activerecord', Ensql::ACTIVERECORD_VERSION
|
8
|
+
require 'active_record'
|
9
|
+
|
10
|
+
module Ensql
|
11
|
+
#
|
12
|
+
# Implements the {Adapter} interface for ActiveRecord. Requires an
|
13
|
+
# ActiveRecord connection to be configured and established. Uses
|
14
|
+
# ActiveRecord::Base for the connection.
|
15
|
+
#
|
16
|
+
# @example
|
17
|
+
# require 'active_record'
|
18
|
+
# ActiveRecord::Base.establish_connection(adapter: 'postgresql', database: 'mydb')
|
19
|
+
# Ensql.adapter = Ensql::ActiveRecordAdapter
|
20
|
+
#
|
21
|
+
# @see Adapter
|
22
|
+
# @see ACTIVERECORD_VERSION Required gem version
|
23
|
+
#
|
24
|
+
module ActiveRecordAdapter
|
25
|
+
extend Adapter
|
26
|
+
|
27
|
+
# @!visibility private
|
28
|
+
def self.fetch_rows(sql)
|
29
|
+
result = connection.exec_query(sql)
|
30
|
+
result.map do |row|
|
31
|
+
# Deserialize column types if needed
|
32
|
+
row.each_with_object({}) do |(column, value), hash|
|
33
|
+
hash[column] = result.column_types[column] ? result.column_types[column].deserialize(value) : value
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# @!visibility private
|
39
|
+
def self.run(sql)
|
40
|
+
connection.execute(sql)
|
41
|
+
end
|
42
|
+
|
43
|
+
# @!visibility private
|
44
|
+
def self.fetch_count(sql)
|
45
|
+
connection.exec_update(sql)
|
46
|
+
end
|
47
|
+
|
48
|
+
# @!visibility private
|
49
|
+
def self.literalize(value)
|
50
|
+
connection.quote(value)
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.connection
|
54
|
+
ActiveRecord::Base.connection
|
55
|
+
end
|
56
|
+
|
57
|
+
private_class_method :connection
|
58
|
+
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../ensql"
|
4
|
+
|
5
|
+
module Ensql
|
6
|
+
#
|
7
|
+
# @abstract Do not use this module directly.
|
8
|
+
#
|
9
|
+
# A common interface for executing SQL statements and retrieving (or not)
|
10
|
+
# their results. Some methods have predefined implementations for convenience
|
11
|
+
# that can be improved in the adapters.
|
12
|
+
#
|
13
|
+
module Adapter
|
14
|
+
|
15
|
+
# @!group 1. Interface Methods
|
16
|
+
|
17
|
+
# @!method literalize(value)
|
18
|
+
#
|
19
|
+
# Convert a Ruby object into a string that can be safely interpolated into
|
20
|
+
# an SQL statement. Strings will be correctly quoted. The precise result
|
21
|
+
# will depend on the adapter and the underlying database driver, but most
|
22
|
+
# RDBMs have limited ways to express literals.
|
23
|
+
#
|
24
|
+
# @return [String] a properly quoted SQL literal
|
25
|
+
#
|
26
|
+
# @see https://www.postgresql.org/docs/13/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
|
27
|
+
# @see https://dev.mysql.com/doc/refman/8.0/en/literals.html
|
28
|
+
# @see https://sqlite.org/lang_expr.html#literal_values_constants_
|
29
|
+
#
|
30
|
+
# @example
|
31
|
+
# literalize("It's quoted") # => "'It''s quoted'"
|
32
|
+
# literalize(1.23) # => "1.23"
|
33
|
+
# literalize(true) # => "1"
|
34
|
+
# literalize(nil) # => "NULL"
|
35
|
+
# literalize(Time.now) # => "'2021-02-22 23:44:28.942947+1300'"
|
36
|
+
|
37
|
+
# @!method fetch_rows(sql)
|
38
|
+
#
|
39
|
+
# Execute the query and return an array of rows represented by { column => field }
|
40
|
+
# hashes. Fields should be deserialised depending on the column type.
|
41
|
+
#
|
42
|
+
# @return [Array<Hash>] rows as hashes keyed by column name
|
43
|
+
|
44
|
+
# @!method fetch_count(sql)
|
45
|
+
#
|
46
|
+
# Execute the statement and return the number of rows affected. Typically
|
47
|
+
# used for DELETE, UPDATE, INSERT, but will work with SELECT on some
|
48
|
+
# databases.
|
49
|
+
#
|
50
|
+
# @return <Integer> the number of rows affected by the statement
|
51
|
+
|
52
|
+
# @!method run(sql)
|
53
|
+
#
|
54
|
+
# Execute the statement on the database without returning any result. This
|
55
|
+
# can avoid the overhead of other fetch_* methods.
|
56
|
+
#
|
57
|
+
# @return <void>
|
58
|
+
|
59
|
+
# @!group 2. Predefined Methods
|
60
|
+
|
61
|
+
# Execute the query and yield each resulting row. This should provide a more
|
62
|
+
# efficient method of iterating through large datasets.
|
63
|
+
#
|
64
|
+
# @yield <Hash> row
|
65
|
+
def fetch_each_row(sql, &block)
|
66
|
+
fetch_rows(sql).each(&block)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Execute the query and return only the first row of the result.
|
70
|
+
# @return <Hash>
|
71
|
+
def fetch_first_row(sql)
|
72
|
+
fetch_rows(sql).first
|
73
|
+
end
|
74
|
+
|
75
|
+
# Execute the query and return only the first column of the result.
|
76
|
+
# @return <Array>
|
77
|
+
def fetch_first_column(sql)
|
78
|
+
fetch_rows(sql).map(&:values).map(&:first)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Execute the query and return only the first field of the first row of the result.
|
82
|
+
def fetch_first_field(sql)
|
83
|
+
fetch_first_row(sql)&.values&.first
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'version'
|
4
|
+
require_relative 'adapter'
|
5
|
+
|
6
|
+
# Ensure our optional dependency has a compatible version
|
7
|
+
gem 'sequel', Ensql::SEQUEL_VERSION
|
8
|
+
require 'sequel'
|
9
|
+
|
10
|
+
module Ensql
|
11
|
+
#
|
12
|
+
# Implements the {Adapter} interface for Sequel. Requires a Sequel connection to
|
13
|
+
# be established. Uses the first connection found in Sequel::DATABASES. You
|
14
|
+
# may want to utilize the relevant extensions to make the most of the
|
15
|
+
# deserialization.
|
16
|
+
#
|
17
|
+
# @example
|
18
|
+
# require 'sequel'
|
19
|
+
# DB = Sequel.connect('postgres://localhost/mydb')
|
20
|
+
# DB.extend(:pg_json)
|
21
|
+
# Ensql.adapter = Ensql::SequelAdapter
|
22
|
+
#
|
23
|
+
# @see Adapter
|
24
|
+
# @see SEQUEL_VERSION Required gem version
|
25
|
+
#
|
26
|
+
module SequelAdapter
|
27
|
+
extend Adapter
|
28
|
+
|
29
|
+
# @!visibility private
|
30
|
+
def self.fetch_rows(sql)
|
31
|
+
db.fetch(sql).map { |r| r.transform_keys(&:to_s) }
|
32
|
+
end
|
33
|
+
|
34
|
+
# @!visibility private
|
35
|
+
def self.fetch_count(sql)
|
36
|
+
db.execute_dui(sql)
|
37
|
+
end
|
38
|
+
|
39
|
+
# @!visibility private
|
40
|
+
def self.run(sql)
|
41
|
+
db << sql
|
42
|
+
end
|
43
|
+
|
44
|
+
# @!visibility private
|
45
|
+
def self.literalize(value)
|
46
|
+
db.literal(value)
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.db
|
50
|
+
Sequel::DATABASES.first or raise Error, "no connection found in Sequel::DATABASES"
|
51
|
+
end
|
52
|
+
|
53
|
+
private_class_method :db
|
54
|
+
|
55
|
+
end
|
56
|
+
end
|
data/lib/ensql/sql.rb
ADDED
@@ -0,0 +1,161 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../ensql"
|
4
|
+
|
5
|
+
module Ensql
|
6
|
+
#
|
7
|
+
# Encapsulates a plain-text SQL statement and optional parameters to interpolate. Interpolation is indicated by one
|
8
|
+
# of the four placeholder formats:
|
9
|
+
#
|
10
|
+
# 1. **Literal:** `%{param}`
|
11
|
+
# - Interpolates `param` as a quoted string or a numeric literal depending on the class.
|
12
|
+
# - `nil` is interpolated as `'NULL'`.
|
13
|
+
# - Other objects depend on the database and the adapter, but most (like `Time`) are serialised as a quoted SQL
|
14
|
+
# string.
|
15
|
+
#
|
16
|
+
# 2. **List Expansion:** `%{(param)}`
|
17
|
+
# - Expands an array to a list of quoted literals.
|
18
|
+
# - Mostly useful for `column IN (1,2)` or postgres row literals.
|
19
|
+
# - Empty arrays are interpolated as `(NULL)` for SQL conformance.
|
20
|
+
# - The parameter will be converted to an Array.
|
21
|
+
#
|
22
|
+
# 3. **Nested List:** `%{param(nested sql)}`
|
23
|
+
# - Takes an array of parameter hashes and interpolates the nested SQL for each Hash in the Array.
|
24
|
+
# - Raises an error if param is nil or a non-hash array.
|
25
|
+
# - Primary useful for SQL `VALUES ()` clauses.
|
26
|
+
#
|
27
|
+
# 4. **SQL Fragment:** `%{!sql_param}`
|
28
|
+
# - Interpolates the parameter without quoting, as a SQL fragment.
|
29
|
+
# - The parameter _must_ be an {Ensql::SQL} object or this will raise an error.
|
30
|
+
# - `nil` will not be interpolated.
|
31
|
+
# - Allows composition of SQL via subqueries.
|
32
|
+
#
|
33
|
+
# Any placeholders in the SQL must be present in the params hash or a KeyError will be raised during interpolation.
|
34
|
+
#
|
35
|
+
# @example
|
36
|
+
# # Interpolate a literal
|
37
|
+
# Ensql.sql('SELECT * FROM users WHERE email > %{date}', date: Date.today)
|
38
|
+
# # SELECT * FROM users WHERE email > '2021-02-22'
|
39
|
+
#
|
40
|
+
# # Interpolate a list
|
41
|
+
# Ensql.sql('SELECT * FROM users WHERE name IN %{(names)}', names: ['user1', 'user2'])
|
42
|
+
# # SELECT * FROM users WHERE name IN ('user1', 'user2')
|
43
|
+
#
|
44
|
+
# # Interpolate a nested VALUES list
|
45
|
+
# Ensql.sql('INSERT INTO users (name, created_at) VALUES %{users( %{name}, now() )}',
|
46
|
+
# users: [{ name: "Claudia Buss" }, { name: "Lundy L'Anglais" }]
|
47
|
+
# )
|
48
|
+
# # INSERT INTO users VALUES ('Claudia Buss', now()), ('Lundy L''Anglais', now())
|
49
|
+
#
|
50
|
+
# # Interpolate a SQL fragement
|
51
|
+
# Ensql.sql('SELECT * FROM users ORDER BY %{!orderby}', orderby: Ensql.sql('name asc'))
|
52
|
+
# # SELECT * FROM users ORDER BY name asc
|
53
|
+
#
|
54
|
+
class SQL
|
55
|
+
|
56
|
+
# @!visibility private
|
57
|
+
def initialize(sql, params={}, name='SQL')
|
58
|
+
@sql = sql
|
59
|
+
@name = name.to_s
|
60
|
+
@params = params
|
61
|
+
end
|
62
|
+
|
63
|
+
# (see Adapter.fetch_rows)
|
64
|
+
def rows
|
65
|
+
adapter.fetch_rows(to_sql)
|
66
|
+
end
|
67
|
+
|
68
|
+
# (see Adapter.fetch_first_row)
|
69
|
+
def first_row
|
70
|
+
adapter.fetch_first_row(to_sql)
|
71
|
+
end
|
72
|
+
|
73
|
+
# (see Adapter.fetch_first_column)
|
74
|
+
def first_column
|
75
|
+
adapter.fetch_first_column(to_sql)
|
76
|
+
end
|
77
|
+
|
78
|
+
# (see Adapter.fetch_first_field)
|
79
|
+
def first_field
|
80
|
+
adapter.fetch_first_field(to_sql)
|
81
|
+
end
|
82
|
+
|
83
|
+
# (see Adapter.fetch_count)
|
84
|
+
def count
|
85
|
+
adapter.fetch_count(to_sql)
|
86
|
+
end
|
87
|
+
|
88
|
+
# (see Adapter.run)
|
89
|
+
def run
|
90
|
+
adapter.run(to_sql)
|
91
|
+
nil
|
92
|
+
end
|
93
|
+
|
94
|
+
# (see Adapter.fetch_each_row)
|
95
|
+
def each_row(&block)
|
96
|
+
adapter.fetch_each_row(to_sql, &block)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Interpolate the params into the SQL statement.
|
100
|
+
#
|
101
|
+
# @raise [Ensql::Error] if any param is missing or invalid.
|
102
|
+
# @return [String] a SQL string with parameters interpolated.
|
103
|
+
def to_sql
|
104
|
+
interpolate(sql, params)
|
105
|
+
end
|
106
|
+
|
107
|
+
private
|
108
|
+
|
109
|
+
attr_reader :sql, :params, :name
|
110
|
+
|
111
|
+
NESTED_LIST = /%{(\w+)\((.+)\)}/m
|
112
|
+
LIST = /%{\((\w+)\)}/
|
113
|
+
SQL_FRAGMENT = /%{!(\w+)}/
|
114
|
+
LITERAL = /%{(\w+)}/
|
115
|
+
|
116
|
+
def interpolate(sql, params)
|
117
|
+
params = params.transform_keys(&:to_s)
|
118
|
+
sql
|
119
|
+
.gsub(NESTED_LIST) { interpolate_nested_list params.fetch($1), $2 }
|
120
|
+
.gsub(LIST) { interpolate_list params.fetch($1) }
|
121
|
+
.gsub(SQL_FRAGMENT) { interpolate_sql params.fetch($1) }
|
122
|
+
.gsub(LITERAL) { literalize params.fetch($1) }
|
123
|
+
rescue => e
|
124
|
+
raise Error, "failed interpolating `#{$1}` into #{name}: #{e}"
|
125
|
+
end
|
126
|
+
|
127
|
+
def interpolate_nested_list(array, nested_sql)
|
128
|
+
raise Error, "array must not be empty" if Array(array).empty?
|
129
|
+
|
130
|
+
Array(array)
|
131
|
+
.map { |attrs| interpolate(nested_sql, Hash(attrs)) }
|
132
|
+
.map { |sql| "(#{sql})" }
|
133
|
+
.join(', ')
|
134
|
+
end
|
135
|
+
|
136
|
+
def interpolate_list(array)
|
137
|
+
return '(NULL)' if Array(array).empty?
|
138
|
+
|
139
|
+
'(' + Array(array).map { |v| literalize v }.join(', ') + ')'
|
140
|
+
end
|
141
|
+
|
142
|
+
def interpolate_sql(sql)
|
143
|
+
return if sql.nil?
|
144
|
+
|
145
|
+
raise "SQL fragment interpolation requires #{self.class}, got #{sql.class}" unless sql.is_a?(self.class)
|
146
|
+
|
147
|
+
sql.to_sql
|
148
|
+
end
|
149
|
+
|
150
|
+
def literalize(value)
|
151
|
+
adapter.literalize value
|
152
|
+
rescue => e
|
153
|
+
raise "error serialising #{value.class} into a SQL literal: #{e}"
|
154
|
+
end
|
155
|
+
|
156
|
+
def adapter
|
157
|
+
Ensql.adapter
|
158
|
+
end
|
159
|
+
|
160
|
+
end
|
161
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ensql
|
4
|
+
# Gem version
|
5
|
+
VERSION = "0.6.0"
|
6
|
+
# Version of the activerecord gem required to use the {ActiveRecordAdapter}
|
7
|
+
ACTIVERECORD_VERSION = ['>= 5.0', '< 6.2'].freeze
|
8
|
+
# Version of the sequel gem required to use the {SequelAdapter}
|
9
|
+
SEQUEL_VERSION = '~> 5.10'
|
10
|
+
end
|
metadata
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ensql
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.6.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Daniel Fone
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-02-23 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Ditch your ORM and embrace the power and simplicity of writing plain
|
14
|
+
SQL again.
|
15
|
+
email:
|
16
|
+
- daniel@fone.net.nz
|
17
|
+
executables: []
|
18
|
+
extensions: []
|
19
|
+
extra_rdoc_files: []
|
20
|
+
files:
|
21
|
+
- ".gitignore"
|
22
|
+
- ".rspec"
|
23
|
+
- ".ruby-version"
|
24
|
+
- ".yardopts"
|
25
|
+
- CHANGELOG.md
|
26
|
+
- Gemfile
|
27
|
+
- Gemfile.lock
|
28
|
+
- LICENSE.txt
|
29
|
+
- README.md
|
30
|
+
- Rakefile
|
31
|
+
- bin/console
|
32
|
+
- bin/rspec
|
33
|
+
- bin/setup
|
34
|
+
- ensql.gemspec
|
35
|
+
- lib/ensql.rb
|
36
|
+
- lib/ensql/active_record_adapter.rb
|
37
|
+
- lib/ensql/adapter.rb
|
38
|
+
- lib/ensql/sequel_adapter.rb
|
39
|
+
- lib/ensql/sql.rb
|
40
|
+
- lib/ensql/version.rb
|
41
|
+
homepage: https://github.com/danielfone/ensql
|
42
|
+
licenses:
|
43
|
+
- MIT
|
44
|
+
metadata: {}
|
45
|
+
post_install_message:
|
46
|
+
rdoc_options: []
|
47
|
+
require_paths:
|
48
|
+
- lib
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 2.4.0
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: '0'
|
59
|
+
requirements: []
|
60
|
+
rubygems_version: 3.2.9
|
61
|
+
signing_key:
|
62
|
+
specification_version: 4
|
63
|
+
summary: Write SQL the safe and simple way
|
64
|
+
test_files: []
|