ensql 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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: []
|