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 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: []