ensql 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.7.2
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --markup markdown
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2021-02-15
4
+
5
+ - Initial release
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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
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: []