ensql 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bc1fdbc384c83a4d0a8ab1544f095320d63d66bc814cc30877f63bdc766be40
4
- data.tar.gz: 7af7a154c0b4480278377f3dae635daf890ed69fd259f6bcffa3a14c3b4a8269
3
+ metadata.gz: 926a86bf4e2b069dfee856bc77567048cca9f5e621d48e255fe3d5f1c041a42d
4
+ data.tar.gz: 294b707b6a8d8394ba12eafe20298bb800ab86584f354bacfe8cbdef66581531
5
5
  SHA512:
6
- metadata.gz: 1d085a19d85b24a3a4d038e137b54b439fafe8b30ec5f5b2e384792af87c00e3daf462ed08a8d16c252568d1f1d6bb1bf390e23c23423a8f48de4085f0f6e487
7
- data.tar.gz: 7ee53cbd22ce7c5c059659b50fccd6a3e0f3b0836f0114d916d1faab3c6cdb7a598a5588102631cab3de6b2c435236939e0b43cdb550c7f068fb76c4eea579ed
6
+ metadata.gz: fd0ed7157a3c0dd791a3635af47e7fb58ec54ab770e38eb1765e224e4674c4065278af952d74f142db42242b4ab7bb2cfb23f89eefbdb39ec3a5af44a3d7867a
7
+ data.tar.gz: 34a692c5053ab53c916faf15522d218365e926fd56aa4bf814eac547110b554b996c5ef6bfed09b76136b43705b00e438ad67508e30814b9734bd6bb6b53d07f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Change Log
2
2
 
3
+ ## [0.6.2] - 2021-03-09
4
+
5
+ - Adds a specialised adapter for PostgreSQL.
6
+ - Uses instances instead of modules for SequelAdapter and ActiveRecordAdapter. The use of the (now) classes as adapters is deprecated.
7
+ - Adds connection pool wrappers for ActiveRecord and Sequel.
8
+ - Ensures SQL#each_row returns nil.
9
+ - Makes adapter attribute thread-safe.
10
+
3
11
  ## [0.6.1] - 2021-02-25
4
12
 
5
13
  - Enables the use of streaming with the SequelAdapter
data/Gemfile.lock CHANGED
@@ -1,7 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ensql (0.6.1)
4
+ ensql (0.6.2)
5
+ connection_pool (>= 0.9.3, < 3)
5
6
 
6
7
  GEM
7
8
  remote: https://rubygems.org/
@@ -18,6 +19,7 @@ GEM
18
19
  tzinfo (~> 2.0)
19
20
  zeitwerk (~> 2.3)
20
21
  concurrent-ruby (1.1.8)
22
+ connection_pool (2.2.3)
21
23
  diff-lcs (1.4.4)
22
24
  docile (1.3.5)
23
25
  i18n (1.8.9)
data/README.md CHANGED
@@ -68,19 +68,33 @@ Or install it manually with:
68
68
 
69
69
  Ensql requires:
70
70
 
71
- * Ruby >= 2.4.0
72
- * Sequel >= 5.9 if using Sequel
73
- * ActiveRecord >= 5.0 if using ActiveRecord
71
+ * ruby >= 2.4.0
72
+ * sequel >= 5.9 if using SequelAdapter
73
+ * activerecord >= 5.0 if using ActiveRecordAdapter
74
+ * pg >= 0.19 if using PostgresAdapter
74
75
 
75
76
  ## Usage
76
77
 
77
78
  Typically, you don't need to configure anything. Ensql will look for Sequel or ActiveRecord (in that order) and load the
78
- appropriate adapter. You can override this if you need to, or configure your own adapter. See [the API docs](https://rubydoc.info/gems/ensql/Ensql/Adapter) for
79
- details of the interface.
79
+ appropriate adapter. You can override this if the wrong adapter is autoconfigured, or if you're using PostgreSQL and
80
+ want to use the much faster and more convenient PostgresAdapter.
80
81
 
81
82
  ```ruby
82
- Ensql.adapter = Ensql::ActiveRecordAdapter # Will use ActiveRecord instead
83
+ # Use ActiveRecord instead of Sequel if both are available
84
+ Ensql.adapter = Ensql::ActiveRecordAdapter.new
85
+
86
+ # Use the PostgreSQL specific adapter, with ActiveRecord's connection pool
87
+ Ensql.adapter = Ensql::PostgresAdapter.new Ensql::ActiveRecordAdapter.pool
88
+
89
+ # Use the PostgreSQL specific adapter, with Sequel's connection pool
90
+ DB = Sequel.connect(ENV['DATABASE_URL'])
91
+ Ensql.adapter = Ensql::PostgresAdapter.new Ensql::SequelAdapter.pool(DB)
92
+
93
+ # Use the PostgreSQL specific adapter, with our own thread-safe connection pool
94
+ Ensql.adapter = Ensql::PostgresAdapter.pool { PG.connect ENV['DATABASE_URL'] }
83
95
  ```
96
+ You can also supply your own adapter (see [the API docs](https://rubydoc.info/gems/ensql/Ensql/Adapter) for details of the interface).
97
+
84
98
 
85
99
  SQL can be supplied directly or read from a file. You're encouraged to organise all but the most trivial statements in
86
100
  their own *.sql files, for the reasons outlined above. You can organise them in whatever way makes most sense for your
@@ -178,21 +192,26 @@ Ensql.run('TRUNCATE logs') # same thing
178
192
  - Maybe we could use type hinting like `%{param:pgarray}` to indicated how to serialise the object as a literal.
179
193
 
180
194
  - Detecting the database and switching to a db specific adapters. This allows us to be more efficient and optimise some
181
- literals in a database specific format, e.g. postgres array literals.
195
+ literals in a database specific format, e.g. PostgreSQL array literals.
182
196
 
183
- - Handling specific connections rather than just grabbing the default.
184
-
185
- - Establishing connections directly.
197
+ - Proper single-row mode support for the pg adapter
186
198
 
187
199
  ## Development
188
200
 
189
201
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You'll
190
- need a running postgres database. You can also run `bin/console` for an interactive prompt that will allow you to
202
+ need a running PostgreSQL database. You can also run `bin/console` for an interactive prompt that will allow you to
191
203
  experiment.
192
204
 
193
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the
194
- version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version,
195
- push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
205
+ To install this gem onto your local machine, run `bundle exec rake install`.
206
+
207
+ ### Release Checklist
208
+
209
+ - [ ] Review changes in master since last release, especially the public API.
210
+ - [ ] Ensure documentation is up to date.
211
+ - [ ] Bump appropriate part of version in `version.rb`.
212
+ - [ ] Update the spec version in each `.lock`.
213
+ - [ ] Update `Changelog.md` with summary of new version.
214
+ - [ ] Run `rake release` to create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
196
215
 
197
216
  ## Contributing
198
217
 
data/ensql.gemspec CHANGED
@@ -29,6 +29,8 @@ Gem::Specification.new do |spec|
29
29
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
30
  spec.require_paths = ["lib"]
31
31
 
32
+ spec.add_dependency "connection_pool", ">= 0.9.3", "<3"
33
+
32
34
  spec.add_development_dependency "rake", "~> 13.0"
33
35
  spec.add_development_dependency "rspec", "~> 3.0"
34
36
  spec.add_development_dependency "simplecov", "~> 0.21.2"
@@ -1,7 +1,8 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- ensql (0.6.0)
4
+ ensql (0.6.2)
5
+ connection_pool (>= 0.9.3, < 3)
5
6
 
6
7
  GEM
7
8
  remote: https://rubygems.org/
@@ -19,6 +20,7 @@ GEM
19
20
  tzinfo (~> 1.1)
20
21
  arel (9.0.0)
21
22
  concurrent-ruby (1.1.8)
23
+ connection_pool (2.2.3)
22
24
  diff-lcs (1.4.4)
23
25
  docile (1.3.5)
24
26
  i18n (1.8.9)
@@ -39,7 +41,7 @@ GEM
39
41
  diff-lcs (>= 1.2.0, < 2.0)
40
42
  rspec-support (~> 3.10.0)
41
43
  rspec-support (3.10.2)
42
- sequel (5.41.0)
44
+ sequel (5.42.0)
43
45
  sequel_pg (1.14.0)
44
46
  pg (>= 0.18.0, != 1.2.0)
45
47
  sequel (>= 4.38.0)
@@ -13,6 +13,7 @@ gemspec path: '../'
13
13
 
14
14
  # Downgrade simplecov for ruby 2.4 compat
15
15
  gem 'simplecov', '~> 0.18.5'
16
+ gem 'connection_pool', '0.9.3'
16
17
 
17
18
  # Optional runtime dependencies
18
19
  group :adapters do
@@ -20,6 +21,6 @@ group :adapters do
20
21
  gem "activerecord", Ensql::SUPPORTED_ACTIVERECORD_VERSIONS.to_s.scan(/\d+.\d+/).first
21
22
  gem "sequel", Ensql::SUPPORTED_SEQUEL_VERSIONS.to_s.scan(/\d+.\d+/).first
22
23
  gem "sqlite3", "~> 1.3.6" # AR version constraint
23
- gem "pg", "~> 0.18" # AR version constraint
24
+ gem "pg", Ensql::SUPPORTED_PG_VERSIONS.to_s.scan(/\d+.\d+/).first
24
25
  gem "sequel_pg"
25
26
  end
@@ -1,7 +1,8 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- ensql (0.6.0)
4
+ ensql (0.6.2)
5
+ connection_pool (>= 0.9.3, < 3)
5
6
 
6
7
  GEM
7
8
  remote: https://rubygems.org/
@@ -19,12 +20,13 @@ GEM
19
20
  tzinfo (~> 1.1)
20
21
  arel (7.1.4)
21
22
  concurrent-ruby (1.1.8)
23
+ connection_pool (0.9.3)
22
24
  diff-lcs (1.4.4)
23
25
  docile (1.3.5)
24
26
  i18n (0.9.5)
25
27
  concurrent-ruby (~> 1.0)
26
28
  minitest (5.14.4)
27
- pg (0.21.0)
29
+ pg (0.19.0)
28
30
  rake (13.0.3)
29
31
  rspec (3.10.0)
30
32
  rspec-core (~> 3.10.0)
@@ -59,8 +61,9 @@ PLATFORMS
59
61
 
60
62
  DEPENDENCIES
61
63
  activerecord (= 5.0)
64
+ connection_pool (= 0.9.3)
62
65
  ensql!
63
- pg (~> 0.18)
66
+ pg (= 0.19)
64
67
  rake (~> 13.0)
65
68
  rspec (~> 3.0)
66
69
  sequel (= 5.9)
data/lib/ensql.rb CHANGED
@@ -76,30 +76,40 @@ module Ensql
76
76
  SQL.new(sql, params).run
77
77
  end
78
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.
79
+ # Get the current connection adapter. If not specified, it will try to
80
+ # autoload an adapter based on the availability of Sequel or ActiveRecord,
81
+ # in that order.
82
82
  #
83
83
  # @example
84
84
  # require 'sequel'
85
- # Ensql.adapter # => Ensql::SequelAdapter
86
- # Ensql.adapter = Ensql::ActiveRecordAdapter # override adapter
87
- # Ensql.adapter = CustomMSSQLAdapater # supply your own adapter
85
+ # Ensql.adapter # => Ensql::SequelAdapter.new
86
+ # Ensql.adapter = Ensql::ActiveRecordAdapter.new # override adapter
87
+ # Ensql.adapter = my_tsql_adapter # supply your own adapter
88
88
  #
89
89
  def adapter
90
- @adapter ||= autoload_adapter
90
+ Thread.current[:ensql_adapter] || Thread.main[:ensql_adapter] ||= autoload_adapter
91
+ end
92
+
93
+ # Set the connection adapter to use. Must implement the interface defined in
94
+ # {Ensql::Adapter}. This uses a thread-local variable so adapters can be
95
+ # switched safely in a multi-threaded web server.
96
+ def adapter=(adapter)
97
+ if adapter.is_a?(Module) && (adapter.name == 'Ensql::SequelAdapter' || adapter.name == 'Ensql::ActiveRecordAdapter')
98
+ warn "Using `#{adapter}` as an adapter is deprecated, use `#{adapter}.new`.", uplevel: 1
99
+ end
100
+
101
+ Thread.current[:ensql_adapter] = adapter
91
102
  end
92
- attr_writer :adapter
93
103
 
94
104
  private
95
105
 
96
106
  def autoload_adapter
97
107
  if defined? Sequel
98
108
  require_relative 'ensql/sequel_adapter'
99
- SequelAdapter
109
+ SequelAdapter.new
100
110
  elsif defined? ActiveRecord
101
111
  require_relative 'ensql/active_record_adapter'
102
- ActiveRecordAdapter
112
+ ActiveRecordAdapter.new
103
113
  else
104
114
  raise Error, "Couldn't autodetect an adapter, please specify manually."
105
115
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'version'
4
4
  require_relative 'adapter'
5
+ require_relative 'pool_wrapper'
5
6
 
6
7
  # Ensure our optional dependency has a compatible version
7
8
  gem 'activerecord', Ensql::SUPPORTED_ACTIVERECORD_VERSIONS
@@ -9,30 +10,60 @@ require 'active_record'
9
10
 
10
11
  module Ensql
11
12
  #
12
- # Implements the {Adapter} interface for ActiveRecord. Requires an
13
- # ActiveRecord connection to be configured and established. Uses
14
- # ActiveRecord::Base for the connection.
13
+ # Wraps an ActiveRecord connection pool to implement the {Adapter} interface
14
+ # for ActiveRecord. Requires an ActiveRecord connection to be configured and
15
+ # established. By default, uses the connection pool on ActiveRecord::Base.
16
+ # Other pools can be passed to the constructor.
15
17
  #
16
18
  # @example
17
19
  # require 'active_record'
18
20
  # ActiveRecord::Base.establish_connection(adapter: 'postgresql', database: 'mydb')
19
- # Ensql.adapter = Ensql::ActiveRecordAdapter
21
+ # Ensql.adapter = Ensql::ActiveRecordAdapter.new
22
+ # # Use database configuration for the Widget model instead
23
+ # Ensql.adapter = Ensql::ActiveRecordAdapter.new(Widget)
20
24
  #
21
25
  # @see SUPPORTED_ACTIVERECORD_VERSIONS
22
26
  #
23
- module ActiveRecordAdapter
24
- extend Adapter
27
+ class ActiveRecordAdapter
28
+ include Adapter
25
29
 
26
- # @!visibility private
27
- def self.fetch_rows(sql)
30
+ # Wrap the raw connections from an Active Record connection pool. This
31
+ # allows us to safely checkout the underlying database connection for use in
32
+ # a database specific adapter.
33
+ #
34
+ # Ensql.adapter = MySqliteAdapter.new(ActiveRecordAdapter.pool)
35
+ #
36
+ # @param base [Class] an ActiveRecord class to source connections from
37
+ # @return [PoolWrapper] a pool adapter for raw connections
38
+ def self.pool(base = ActiveRecord::Base)
39
+ PoolWrapper.new do |client_block|
40
+ base.connection_pool.with_connection { |connection| client_block.call connection.raw_connection }
41
+ end
42
+ end
43
+
44
+ # Support deprecated class method interface
45
+ class << self
46
+ require 'forwardable'
47
+ extend Forwardable
48
+
49
+ delegate [:literalize, :run, :fetch_count, :fetch_each_row, :fetch_rows, :fetch_first_column, :fetch_first_field, :fetch_first_row] => :new
50
+ end
51
+
52
+ # @param base [Class] an ActiveRecord class to source connections from
53
+ def initialize(base = ActiveRecord::Base)
54
+ @base = base
55
+ end
56
+
57
+ # @visibility private
58
+ def fetch_rows(sql)
28
59
  fetch_each_row(sql).to_a
29
60
  end
30
61
 
31
- # @!visibility private
32
- def self.fetch_each_row(sql, &block)
62
+ # @visibility private
63
+ def fetch_each_row(sql, &block)
33
64
  return to_enum(:fetch_each_row, sql) unless block_given?
34
65
 
35
- result = connection.exec_query(sql)
66
+ result = with_connection { |c| c.exec_query(sql) }
36
67
  # AR populates `column_types` with the types of any columns that haven't
37
68
  # already been type casted by pg decoders. If present, we need to
38
69
  # deserialize them now.
@@ -43,32 +74,31 @@ module Ensql
43
74
  end
44
75
  end
45
76
 
46
- # @!visibility private
47
- def self.run(sql)
48
- connection.execute(sql)
77
+ # @visibility private
78
+ def run(sql)
79
+ with_connection { |c| c.execute(sql) }
80
+ nil
49
81
  end
50
82
 
51
- # @!visibility private
52
- def self.fetch_count(sql)
53
- connection.exec_update(sql)
83
+ # @visibility private
84
+ def fetch_count(sql)
85
+ with_connection { |c| c.exec_update(sql) }
54
86
  end
55
87
 
56
- # @!visibility private
57
- def self.literalize(value)
58
- connection.quote(value)
88
+ # @visibility private
89
+ def literalize(value)
90
+ with_connection { |c| c.quote(value) }
59
91
  end
60
92
 
61
- def self.connection
62
- ActiveRecord::Base.connection
63
- end
93
+ private
64
94
 
65
- def self.deserialize_types(row, column_types)
66
- row.each_with_object({}) { |(column, value), hash|
67
- hash[column] = column_types[column]&.deserialize(value) || value
68
- }
95
+ def with_connection(&block)
96
+ @base.connection_pool.with_connection(&block)
69
97
  end
70
98
 
71
- private_class_method :connection, :deserialize_types
72
-
99
+ def deserialize_types(row, column_types)
100
+ column_types.each { |column, type| row[column] = type.deserialize(row[column]) }
101
+ row
102
+ end
73
103
  end
74
104
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ensql
4
+ # Wrap a 3rd-party connection pool with a standard interface. Connections can be checked out by {with}
5
+ class PoolWrapper
6
+
7
+ # Wraps a block for accessing a connection from a pool.
8
+ #
9
+ # PoolWrapper.new do |client_block|
10
+ # my_connection_pool.with_connection(&client_block)
11
+ # end
12
+ def initialize(&connection_block)
13
+ @connection_block = connection_block
14
+ end
15
+
16
+ # Get a connection from our source pool
17
+ # @yield [connection] the database-specific connection
18
+ def with(&client_block)
19
+ @connection_block.call(client_block)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'version'
4
+ require_relative 'adapter'
5
+
6
+ gem 'pg', Ensql::SUPPORTED_PG_VERSIONS
7
+ require 'pg'
8
+ require 'connection_pool'
9
+
10
+ module Ensql
11
+ # Wraps a pool of PG connections to implement the {Adapter} interface. The
12
+ # adapter can use a 3rd-party pool (e.g. from ActiveRecord of Sequel) or
13
+ # manage its own using the simple
14
+ # [connection_pool gem](https://github.com/mperham/connection_pool).
15
+ #
16
+ # This adapter is much faster and offers much better PostgreSQL specific
17
+ # parameter interpolation than the framework adapters.
18
+ #
19
+ # @example
20
+ # # Use with ActiveRecord's connection pool
21
+ # Ensql.adapter = Ensql::PostgresAdapter.new(Ensql::ActiveRecordAdapter.pool)
22
+ #
23
+ # # Use with Sequel's connection pool
24
+ # DB = Sequel.connect(ENV['DATABASE_URL'])
25
+ # Ensql.adapter = Ensql::PostgresAdapter.new(Ensql::SequelAdapter.pool(DB))
26
+ #
27
+ # # Use with our own thread-safe connection pool
28
+ # Ensql.adapter = Ensql::PostgresAdapter.pool { PG.connect ENV['DATABASE_URL'] }
29
+ # Ensql.adapter = Ensql::PostgresAdapter.pool(size: 5) { PG.connect ENV['DATABASE_URL'] }
30
+ #
31
+ # @see SUPPORTED_PG_VERSIONS
32
+ #
33
+ class PostgresAdapter
34
+ include Adapter
35
+
36
+ # Set up a connection pool using the supplied block to initialise connections.
37
+ #
38
+ # PostgresAdapter.pool(size: 20) { PG.connect ENV['DATABASE_URL'] }
39
+ #
40
+ # @param pool_opts are sent straight to the ConnectionPool initializer.
41
+ # @option pool_opts [Integer] timeout (5) number of seconds to wait for a connection if none currently available.
42
+ # @option pool_opts [Integer] size (5) number of connections to pool.
43
+ # @yieldreturn [PG::Connection] a new connection.
44
+ def self.pool(**pool_opts, &connection_block)
45
+ new ConnectionPool.new(**pool_opts, &connection_block)
46
+ end
47
+
48
+ # @param pool [PoolWrapper, ConnectionPool, #with] a object that yields a PG::Connection using `#with`
49
+ def initialize(pool)
50
+ @pool = pool
51
+ @quoter = PG::TextEncoder::QuotedLiteral.new
52
+ @result_type_map = @pool.with { |c| PG::BasicTypeMapForResults.new(c) }
53
+ @query_type_map = @pool.with { |c| build_query_type_map(c) }
54
+ end
55
+
56
+ # @visibility private
57
+ def run(sql)
58
+ execute(sql) { nil }
59
+ end
60
+
61
+ # @visibility private
62
+ def literalize(value)
63
+ case value
64
+ when NilClass then 'NULL'
65
+ when Numeric, TrueClass, FalseClass then value.to_s
66
+ when String then @quoter.encode(value)
67
+ else
68
+ @quoter.encode(serialize(value))
69
+ end
70
+ end
71
+
72
+ # @visibility private
73
+ def fetch_count(sql)
74
+ execute(sql, &:cmd_tuples)
75
+ end
76
+
77
+ # @visibility private
78
+ def fetch_first_field(sql)
79
+ fetch_result(sql) { |res| res.getvalue(0, 0) if res.ntuples > 0 && res.nfields > 0 }
80
+ end
81
+
82
+ # @visibility private
83
+ def fetch_first_row(sql)
84
+ fetch_result(sql) { |res| res[0] if res.ntuples > 0 }
85
+ end
86
+
87
+ # @visibility private
88
+ def fetch_first_column(sql)
89
+ # Return an array of nils if we don't have a column
90
+ fetch_result(sql) { |res| res.nfields > 0 ? res.column_values(0) : Array.new(res.ntuples) }
91
+ end
92
+
93
+ # @visibility private
94
+ def fetch_each_row(sql, &block)
95
+ return to_enum(:fetch_each_row, sql) unless block_given?
96
+
97
+ fetch_result(sql) { |res| res.each(&block) }
98
+ end
99
+
100
+ # @visibility private
101
+ def fetch_rows(sql)
102
+ fetch_result(sql, &:to_a)
103
+ end
104
+
105
+ private
106
+
107
+ def fetch_result(sql)
108
+ execute(sql) do |res|
109
+ res.type_map = @result_type_map
110
+ yield res
111
+ end
112
+ end
113
+
114
+ def execute(sql, &block)
115
+ @pool.with { |c| c.async_exec(sql, &block) }
116
+ end
117
+
118
+ # Use PG's built-in type mapping to serialize objects into SQL strings.
119
+ def serialize(value)
120
+ coder = encoder_for(value) or raise TypeError, "No SQL serializer for #{value.class}"
121
+ coder.encode(value)
122
+ end
123
+
124
+ def encoder_for(value)
125
+ coder = @query_type_map[value.class]
126
+ # Handle the weird case where coder can be a method name
127
+ coder.is_a?(Symbol) ? @query_type_map.send(coder, value) : coder
128
+ end
129
+
130
+ # Ensure encoders are set up for old versions of the pg gem
131
+ def build_query_type_map(connection)
132
+ map = PG::BasicTypeMapForQueries.new(connection)
133
+ map[Date] ||= PG::TextEncoder::Date.new
134
+ map[Time] ||= PG::TextEncoder::TimestampWithoutTimeZone.new
135
+ map[Hash] ||= PG::TextEncoder::JSON.new
136
+ map[BigDecimal] ||= NumericEncoder.new
137
+ map
138
+ end
139
+ end
140
+
141
+ # PG < 1.1.0 doesn't have a numeric decoder
142
+ # This is copied from https://github.com/ged/ruby-pg/commit/d4ae41bb8fd447c92ef9c8810ec932acd03e0293
143
+ # :nocov:
144
+ unless defined? PG::TextEncoder::Numeric
145
+ class NumericDecoder < PG::SimpleDecoder
146
+ def decode(string, tuple=nil, field=nil)
147
+ BigDecimal(string)
148
+ end
149
+ end
150
+ class NumericEncoder < PG::SimpleEncoder
151
+ def encode(decimal)
152
+ decimal.to_s('F')
153
+ end
154
+ end
155
+ private_constant :NumericDecoder, :NumericEncoder
156
+ PG::BasicTypeRegistry.register_type(0, 'numeric', NumericEncoder, NumericDecoder)
157
+ end
158
+ # :nocov:
159
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'version'
4
4
  require_relative 'adapter'
5
+ require_relative 'pool_wrapper'
5
6
 
6
7
  # Ensure our optional dependency has a compatible version
7
8
  gem 'sequel', Ensql::SUPPORTED_SEQUEL_VERSIONS
@@ -9,15 +10,16 @@ require 'sequel'
9
10
 
10
11
  module Ensql
11
12
  #
12
- # Implements the {Adapter} interface for Sequel. Requires a Sequel connection
13
- # to be established. Uses the first connection found in Sequel::DATABASES. You
14
- # may want to utilize the relevant extensions to make the most of
15
- # deserialization and other database features.
13
+ # Wraps a Sequel::Database to implement the {Adapter} interface for Sequel.
14
+ # You may want to utilize the relevant Sequel extensions to make the most of
15
+ # database-specific deserialization and other features. By default, uses the
16
+ # first database in Sequel::Databases. Other databases can be passed to the
17
+ # constructor.
16
18
  #
17
19
  # require 'sequel'
18
20
  # DB = Sequel.connect('postgres://localhost/mydb')
19
21
  # DB.extend(:pg_json)
20
- # Ensql.adapter = Ensql::SequelAdapter
22
+ # Ensql.adapter = Ensql::SequelAdapter.new(DB)
21
23
  #
22
24
  # To stream rows, configure streaming on the connection and use
23
25
  # {SQL.each_row}
@@ -25,6 +27,7 @@ module Ensql
25
27
  # DB = Sequel.connect('postgresql:/')
26
28
  # DB.extension(:pg_streaming)
27
29
  # DB.stream_all_queries = true
30
+ # Ensql.adapter = Ensql::SequelAdapter.new(DB)
28
31
  # Ensql.sql("select * from large_table").each_row do |row|
29
32
  # # This now yields each row in single-row mode.
30
33
  # # The connection cannot be used for other queries while this is streaming.
@@ -32,41 +35,71 @@ module Ensql
32
35
  #
33
36
  # @see SUPPORTED_SEQUEL_VERSIONS
34
37
  #
35
- module SequelAdapter
36
- extend Adapter
38
+ class SequelAdapter
39
+ include Adapter
37
40
 
38
- # @!visibility private
39
- def self.fetch_rows(sql)
41
+ # Support deprecated class method interface
42
+ class << self
43
+ require 'forwardable'
44
+ extend Forwardable
45
+
46
+ delegate [:literalize, :run, :fetch_count, :fetch_each_row, :fetch_rows, :fetch_first_column, :fetch_first_field, :fetch_first_row] => :new
47
+ end
48
+
49
+ # Wrap the raw connections from a Sequel::Database connection pool. This
50
+ # allows us to safely checkout the underlying database connection for use in
51
+ # a database specific adapter.
52
+ #
53
+ # Ensql.adapter = MySqliteAdapter.new(SequelAdapter.pool)
54
+ #
55
+ # @param db [Sequel::Database]
56
+ # @return [PoolWrapper] a pool adapter for raw connections
57
+ def self.pool(db)
58
+ PoolWrapper.new do |client_block|
59
+ db.pool.hold(&client_block)
60
+ end
61
+ end
62
+
63
+ # @param db [Sequel::Database]
64
+ def initialize(db = first_configured_database)
65
+ @db = db
66
+ end
67
+
68
+ # @visibility private
69
+ def fetch_rows(sql)
40
70
  fetch_each_row(sql).to_a
41
71
  end
42
72
 
43
- # @!visibility private
44
- def self.fetch_each_row(sql)
73
+ # @visibility private
74
+ def fetch_each_row(sql)
45
75
  return to_enum(:fetch_each_row, sql) unless block_given?
46
76
 
47
77
  db.fetch(sql) { |r| yield r.transform_keys(&:to_s) }
48
78
  end
49
79
 
50
- # @!visibility private
51
- def self.fetch_count(sql)
80
+ # @visibility private
81
+ def fetch_count(sql)
52
82
  db.execute_dui(sql)
53
83
  end
54
84
 
55
- # @!visibility private
56
- def self.run(sql)
85
+ # @visibility private
86
+ def run(sql)
57
87
  db << sql
88
+ nil
58
89
  end
59
90
 
60
- # @!visibility private
61
- def self.literalize(value)
91
+ # @visibility private
92
+ def literalize(value)
62
93
  db.literal(value)
63
94
  end
64
95
 
65
- def self.db
66
- Sequel::DATABASES.first or raise Error, "no connection found in Sequel::DATABASES"
67
- end
96
+ private
68
97
 
69
- private_class_method :db
98
+ attr_reader :db
99
+
100
+ def first_configured_database
101
+ Sequel::DATABASES.first or raise Error, "no database found in Sequel::DATABASES"
102
+ end
70
103
 
71
104
  end
72
105
  end
data/lib/ensql/sql.rb CHANGED
@@ -95,6 +95,7 @@ module Ensql
95
95
  # (see Adapter.fetch_each_row)
96
96
  def each_row(&block)
97
97
  adapter.fetch_each_row(to_sql, &block)
98
+ nil
98
99
  end
99
100
 
100
101
  # Interpolate the params into the SQL statement.
data/lib/ensql/version.rb CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  module Ensql
4
4
  # Gem version
5
- VERSION = "0.6.1"
5
+ VERSION = "0.6.2"
6
6
  # Versions of activerecord compatible with the {ActiveRecordAdapter}
7
7
  SUPPORTED_ACTIVERECORD_VERSIONS = ['>= 5.0', '< 6.2'].freeze
8
8
  # Versions of sequel compatible with the {SequelAdapter}
9
9
  SUPPORTED_SEQUEL_VERSIONS = '~> 5.9'
10
+ # Versions of pg compatibile with the {PostgresAdapter}
11
+ SUPPORTED_PG_VERSIONS = ['>= 0.19', '< 2'].freeze
10
12
  end
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # Compare operations performed using each adapter
4
+ #
5
+
6
+ ENV['TZ'] = 'UTC'
7
+
8
+ require 'benchmark/ips'
9
+
10
+ require_relative 'lib/ensql/active_record_adapter'
11
+ require_relative 'lib/ensql/sequel_adapter'
12
+ require_relative 'lib/ensql/postgres_adapter'
13
+
14
+ ActiveRecord::Base.establish_connection(adapter: "postgresql")
15
+ DB = Sequel.connect("postgresql:/")
16
+ DB.extension(:pg_json)
17
+
18
+ adapters = {
19
+ 'pg ': Ensql::PostgresAdapter.new { PG::Connection.open },
20
+ 'ar ': Ensql::ActiveRecordAdapter.new(ActiveRecord::Base.connection_pool),
21
+ 'seq ': Ensql::SequelAdapter.new(DB),
22
+ 'pg-ar ': Ensql::PostgresAdapter.new(Ensql::ActiveRecordAdapter.pool),
23
+ 'pg-seq ': Ensql::PostgresAdapter.new(Ensql::SequelAdapter.pool(DB)),
24
+ }
25
+
26
+ ADAPTER = adapters.values.first
27
+
28
+ ADAPTER.run('drop table if exists number_benchmark')
29
+ ADAPTER.run('create table number_benchmark as select generate_series(1,100) as number')
30
+
31
+ adapter_tests = {
32
+ 'literalize (String)': [:literalize, "It's quoted"],
33
+ 'literalize (Long String)': [:literalize, "It's quoted" * 1000],
34
+ 'literalize (Time)': [:literalize, Time.now],
35
+ 'literalize (Int)': [:literalize, 1234],
36
+ 'literalize (bool)': [:literalize, true],
37
+ 'run INSERT': [:run, 'insert into number_benchmark values (999)'],
38
+ 'run SET': [:run, "set time zone UTC"],
39
+ 'run SELECT': [:run, 'select generate_series(1,100)'],
40
+ 'count UPDATE': [:fetch_count, 'update number_benchmark set number = number + 1'],
41
+ 'count SELECT': [:fetch_count, 'select generate_series(1,100)'],
42
+ 'first column': [:fetch_first_column, 'select generate_series(1,100)'],
43
+ 'first column (of many)': [:fetch_first_column, 'select *, now() from generate_series(1,100) as number'],
44
+ 'first field': [:fetch_first_field, 'select 1'],
45
+ 'first field with cast': [:fetch_first_field, "select cast('2021-01-01' as timestamp)"],
46
+ 'first field (of many)': [:fetch_first_field, 'select generate_series(1,100)'],
47
+ 'first row': [:fetch_first_row, "select 1, 2, 3"],
48
+ 'first row (cast)': [:fetch_first_row, "select cast('2021-01-01' as timestamp), cast('[1,2,3]' as json)"],
49
+ 'first row (of many)': [:fetch_first_row, "select generate_series(1, 100)"],
50
+ 'rows (1)': [:fetch_rows, "select 1, 1"],
51
+ 'rows (100)': [:fetch_rows, "select 1, 1, generate_series(1, 100)"],
52
+ 'rows (100,cast)': [:fetch_rows, "select cast('2021-01-01' as timestamp), cast('[1,2,3]' as json), generate_series(1, 100)"],
53
+ 'rows (100000)': [:fetch_rows, "select 1, 1, generate_series(1, 100000)"],
54
+ }
55
+
56
+ fetch_each_row_tests = {
57
+ 'each_row (1)': [:fetch_each_row, "select 1, 1" ],
58
+ 'each_row (100)': [:fetch_each_row, "select 1, 1, generate_series(1, 100)"],
59
+ 'each_row (100,cast)': [:fetch_each_row, "select cast('2021-01-01' as timestamp), cast('[1,2,3]' as json), generate_series(1, 100)"],
60
+ 'each_row (100000)': [:fetch_each_row, "select 1, 1, generate_series(1, 100000)"],
61
+ }
62
+
63
+ # Verify results are the same
64
+ adapter_tests.each do |name, args|
65
+ results = adapters.map { |n, a| [n, a.send(*args)] }.uniq { |n, result| result }
66
+ next if results.count == 1
67
+
68
+ warn "Differing results for #{name}: #{args}"
69
+ results.each { |n, result| warn " #{n} => #{result.inspect[0..500]}" }
70
+ end
71
+
72
+ # Compare times
73
+ adapter_tests.each do |test_name, args|
74
+ puts args.map { |a| a.inspect[0..100] }.join(' ')
75
+
76
+ Benchmark.ips(quiet: true) do |x|
77
+ x.config(stats: :bootstrap, confidence: 95, warmup: 0.2, time: 0.5)
78
+
79
+ adapters.each do |name, adapter|
80
+ x.report("#{test_name} - #{name}") { adapter.send(*args) }
81
+ end
82
+
83
+ x.compare!
84
+ end
85
+ end
86
+
87
+ fetch_each_row_tests.each do |test_name, args|
88
+ Benchmark.ips(quiet: true) do |x|
89
+ x.config(stats: :bootstrap, confidence: 95, warmup: 0.2, time: 0.5)
90
+
91
+ adapters.each do |name, adapter|
92
+ x.report("#{test_name} - #{name}") { adapter.send(*args) { |r| r } }
93
+ end
94
+
95
+ x.compare!
96
+ end
97
+ end
98
+
99
+ ADAPTER.run('drop table number_benchmark')
metadata CHANGED
@@ -1,15 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ensql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Fone
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-02-25 00:00:00.000000000 Z
11
+ date: 2021-03-09 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: connection_pool
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.9.3
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 0.9.3
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3'
13
33
  - !ruby/object:Gem::Dependency
14
34
  name: rake
15
35
  requirement: !ruby/object:Gem::Requirement
@@ -96,9 +116,12 @@ files:
96
116
  - lib/ensql.rb
97
117
  - lib/ensql/active_record_adapter.rb
98
118
  - lib/ensql/adapter.rb
119
+ - lib/ensql/pool_wrapper.rb
120
+ - lib/ensql/postgres_adapter.rb
99
121
  - lib/ensql/sequel_adapter.rb
100
122
  - lib/ensql/sql.rb
101
123
  - lib/ensql/version.rb
124
+ - perf/adapter_benchmark.rb
102
125
  homepage: https://github.com/danielfone/ensql
103
126
  licenses:
104
127
  - MIT