ensql 0.6.0 → 0.6.5

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.
data/lib/ensql/adapter.rb CHANGED
@@ -1,8 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../ensql"
3
+ require_relative "error"
4
4
 
5
5
  module Ensql
6
+ class << self
7
+ # Get the current connection adapter. If not specified, it will try to
8
+ # autoload an adapter based on the availability of Sequel or ActiveRecord,
9
+ # in that order.
10
+ #
11
+ # @example
12
+ # require 'sequel'
13
+ # Ensql.adapter # => Ensql::SequelAdapter.new
14
+ # Ensql.adapter = Ensql::ActiveRecordAdapter.new # override adapter
15
+ # Ensql.adapter = my_tsql_adapter # supply your own adapter
16
+ #
17
+ def adapter
18
+ Thread.current[:ensql_adapter] || Thread.main[:ensql_adapter] ||= autoload_adapter
19
+ end
20
+
21
+ # Set the connection adapter to use. Must implement the interface defined in
22
+ # {Ensql::Adapter}. This uses a thread-local variable so adapters can be
23
+ # switched safely in a multi-threaded web server.
24
+ def adapter=(adapter)
25
+ if adapter.is_a?(Module) && (adapter.name == "Ensql::SequelAdapter" || adapter.name == "Ensql::ActiveRecordAdapter")
26
+ warn "Using `#{adapter}` as an adapter is deprecated, use `#{adapter}.new`.", uplevel: 1
27
+ end
28
+
29
+ Thread.current[:ensql_adapter] = adapter
30
+ end
31
+
32
+ private
33
+
34
+ def autoload_adapter
35
+ if defined? Sequel
36
+ require_relative "sequel_adapter"
37
+ SequelAdapter.new
38
+ elsif defined? ActiveRecord
39
+ require_relative "active_record_adapter"
40
+ ActiveRecordAdapter.new
41
+ else
42
+ raise Error, "Couldn't autodetect an adapter, please specify manually."
43
+ end
44
+ end
45
+ end
46
+
6
47
  #
7
48
  # @abstract Do not use this module directly.
8
49
  #
@@ -11,7 +52,6 @@ module Ensql
11
52
  # that can be improved in the adapters.
12
53
  #
13
54
  module Adapter
14
-
15
55
  # @!group 1. Interface Methods
16
56
 
17
57
  # @!method literalize(value)
@@ -41,6 +81,13 @@ module Ensql
41
81
  #
42
82
  # @return [Array<Hash>] rows as hashes keyed by column name
43
83
 
84
+ # @!method fetch_each_row(sql)
85
+ #
86
+ # Execute the query and yield each resulting row. This should provide a more
87
+ # efficient method of iterating through large datasets.
88
+ #
89
+ # @yield <Hash> row
90
+
44
91
  # @!method fetch_count(sql)
45
92
  #
46
93
  # Execute the statement and return the number of rows affected. Typically
@@ -58,18 +105,10 @@ module Ensql
58
105
 
59
106
  # @!group 2. Predefined Methods
60
107
 
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
108
  # Execute the query and return only the first row of the result.
70
109
  # @return <Hash>
71
110
  def fetch_first_row(sql)
72
- fetch_rows(sql).first
111
+ fetch_each_row(sql).first
73
112
  end
74
113
 
75
114
  # Execute the query and return only the first column of the result.
@@ -82,6 +121,5 @@ module Ensql
82
121
  def fetch_first_field(sql)
83
122
  fetch_first_row(sql)&.values&.first
84
123
  end
85
-
86
124
  end
87
125
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ensql
4
+ # Wrapper for errors raised by Ensql
5
+ class Error < StandardError; end
6
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sql"
4
+ require_relative "error"
5
+
6
+ module Ensql
7
+ class << self
8
+ # Path to search for *.sql queries in, defaults to "sql/". For example, if
9
+ # {sql_path} is set to 'app/queries', `load_sql('users/active')` will read
10
+ # 'app/queries/users/active.sql'.
11
+ # @see .load_sql
12
+ #
13
+ # @example
14
+ # Ensql.sql_path = Rails.root.join('app/queries')
15
+ #
16
+ def sql_path
17
+ @sql_path ||= "sql"
18
+ end
19
+ attr_writer :sql_path
20
+
21
+ # Load SQL from a file within {sql_path}. This is the recommended way to
22
+ # manage SQL in a non-trivial project. For details of how to write
23
+ # interpolation placeholders, see {SQL}.
24
+ #
25
+ # @see .sql_path=
26
+ # @return [Ensql::SQL]
27
+ #
28
+ # @example
29
+ # Ensql.load_sql('users/activity', report_params)
30
+ # Ensql.load_sql(:upsert_users, imported_users_attrs)
31
+ #
32
+ def load_sql(name, params = {})
33
+ path = File.join(sql_path, "#{name}.sql")
34
+ SQL.new(File.read(path), params, name)
35
+ rescue Errno::ENOENT
36
+ raise Error, "couldn't load SQL from file '#{path}' (sql_path: '#{sql_path}')"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
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
+ # Wraps a block for accessing a connection from a pool.
7
+ #
8
+ # PoolWrapper.new do |client_block|
9
+ # my_connection_pool.with_connection(&client_block)
10
+ # end
11
+ def initialize(&connection_block)
12
+ @connection_block = connection_block
13
+ end
14
+
15
+ # Get a connection from our source pool
16
+ # @yield [connection] the database-specific connection
17
+ def with(&client_block)
18
+ @connection_block.call(client_block)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,177 @@
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. See {query_type_map}
18
+ # for options.
19
+ #
20
+ # @example
21
+ # # Use with ActiveRecord's connection pool
22
+ # Ensql.adapter = Ensql::PostgresAdapter.new(Ensql::ActiveRecordAdapter.pool)
23
+ #
24
+ # # Use with Sequel's connection pool
25
+ # DB = Sequel.connect(ENV['DATABASE_URL'])
26
+ # Ensql.adapter = Ensql::PostgresAdapter.new(Ensql::SequelAdapter.pool(DB))
27
+ #
28
+ # # Use with our own thread-safe connection pool
29
+ # Ensql.adapter = Ensql::PostgresAdapter.pool { PG.connect ENV['DATABASE_URL'] }
30
+ # Ensql.adapter = Ensql::PostgresAdapter.pool(size: 5) { PG.connect ENV['DATABASE_URL'] }
31
+ #
32
+ # @see SUPPORTED_PG_VERSIONS
33
+ #
34
+ class PostgresAdapter
35
+ include Adapter
36
+
37
+ # Set up a connection pool using the supplied block to initialise connections.
38
+ #
39
+ # PostgresAdapter.pool(size: 20) { PG.connect ENV['DATABASE_URL'] }
40
+ #
41
+ # @param pool_opts are sent straight to the ConnectionPool initializer.
42
+ # @option pool_opts [Integer] timeout (5) number of seconds to wait for a connection if none currently available.
43
+ # @option pool_opts [Integer] size (5) number of connections to pool.
44
+ # @yieldreturn [PG::Connection] a new connection.
45
+ def self.pool(**pool_opts, &connection_block)
46
+ new ConnectionPool.new(**pool_opts, &connection_block)
47
+ end
48
+
49
+ # @param pool [PoolWrapper, ConnectionPool, #with] a object that yields a PG::Connection using `#with`
50
+ def initialize(pool)
51
+ @pool = pool
52
+ @quoter = PG::TextEncoder::QuotedLiteral.new
53
+ end
54
+
55
+ # A map for encoding Ruby objects into PostgreSQL literals based on their
56
+ # class. You can add additional class mappings to suit your needs. See
57
+ # https://rubydoc.info/gems/pg/PG/BasicTypeRegistry for details of adding
58
+ # your own encoders and decoders.
59
+ #
60
+ # # Encode any `IPAddr` objects for interpolation using the `InetEncoder`.
61
+ # Ensql.adapter.query_type_map[IPAddr] = InetEncoder.new
62
+ #
63
+ # # Deserialize `inet` columns as IPAddr objects.
64
+ # PG::BasicTypeRegistry.register_type(0, 'inet', InetEncoder, InetDecoder)
65
+ #
66
+ # @return [PG::TypeMapByClass]
67
+ def query_type_map
68
+ @query_type_map ||= @pool.with do |connection|
69
+ map = PG::BasicTypeMapForQueries.new(connection)
70
+ # Ensure encoders are set up for old versions of the pg gem
71
+ map[Date] ||= PG::TextEncoder::Date.new
72
+ map[Time] ||= PG::TextEncoder::TimestampWithoutTimeZone.new
73
+ map[Hash] ||= PG::TextEncoder::JSON.new
74
+ map[BigDecimal] ||= NumericEncoder.new
75
+ map
76
+ end
77
+ end
78
+
79
+ # @visibility private
80
+ def run(sql)
81
+ execute(sql) { nil }
82
+ end
83
+
84
+ # @visibility private
85
+ def literalize(value)
86
+ case value
87
+ when NilClass then "NULL"
88
+ when Numeric, TrueClass, FalseClass then value.to_s
89
+ when String then @quoter.encode(value)
90
+ else
91
+ @quoter.encode(serialize(value))
92
+ end
93
+ end
94
+
95
+ # @visibility private
96
+ def fetch_count(sql)
97
+ execute(sql, &:cmd_tuples)
98
+ end
99
+
100
+ # @visibility private
101
+ def fetch_first_field(sql)
102
+ fetch_result(sql) { |res| res.getvalue(0, 0) if res.ntuples > 0 && res.nfields > 0 }
103
+ end
104
+
105
+ # @visibility private
106
+ def fetch_first_row(sql)
107
+ fetch_result(sql) { |res| res[0] if res.ntuples > 0 }
108
+ end
109
+
110
+ # @visibility private
111
+ def fetch_first_column(sql)
112
+ # Return an array of nils if we don't have a column
113
+ fetch_result(sql) { |res| res.nfields > 0 ? res.column_values(0) : Array.new(res.ntuples) }
114
+ end
115
+
116
+ # @visibility private
117
+ def fetch_each_row(sql, &block)
118
+ return to_enum(:fetch_each_row, sql) unless block
119
+
120
+ fetch_result(sql) { |res| res.each(&block) }
121
+ end
122
+
123
+ # @visibility private
124
+ def fetch_rows(sql)
125
+ fetch_result(sql, &:to_a)
126
+ end
127
+
128
+ private
129
+
130
+ def fetch_result(sql)
131
+ execute(sql) do |res|
132
+ res.type_map = result_type_map
133
+ yield res
134
+ end
135
+ end
136
+
137
+ def execute(sql, &block)
138
+ @pool.with { |c| c.async_exec(sql, &block) }
139
+ end
140
+
141
+ # Use PG's built-in type mapping to serialize objects into SQL strings.
142
+ def serialize(value)
143
+ (coder = encoder_for(value)) || raise(TypeError, "No SQL serializer for #{value.class}")
144
+ coder.encode(value)
145
+ end
146
+
147
+ def encoder_for(value)
148
+ coder = query_type_map[value.class]
149
+ # Handle the weird case where coder can be a method name
150
+ coder.is_a?(Symbol) ? query_type_map.send(coder, value) : coder
151
+ end
152
+
153
+ def result_type_map
154
+ @result_type_map ||= @pool.with { |c| PG::BasicTypeMapForResults.new(c) }
155
+ end
156
+ end
157
+
158
+ # PG < 1.1.0 doesn't have a numeric decoder
159
+ # This is copied from https://github.com/ged/ruby-pg/commit/d4ae41bb8fd447c92ef9c8810ec932acd03e0293
160
+ # :nocov:
161
+ unless defined? PG::TextEncoder::Numeric
162
+ class NumericDecoder < PG::SimpleDecoder
163
+ def decode(string, tuple = nil, field = nil)
164
+ BigDecimal(string)
165
+ end
166
+ end
167
+
168
+ class NumericEncoder < PG::SimpleEncoder
169
+ def encode(decimal)
170
+ decimal.to_s("F")
171
+ end
172
+ end
173
+ private_constant :NumericDecoder, :NumericEncoder
174
+ PG::BasicTypeRegistry.register_type(0, "numeric", NumericEncoder, NumericDecoder)
175
+ end
176
+ # :nocov:
177
+ end
@@ -1,56 +1,105 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'version'
4
- require_relative 'adapter'
3
+ require_relative "version"
4
+ require_relative "adapter"
5
+ require_relative "pool_wrapper"
6
+ require_relative "error"
5
7
 
6
8
  # Ensure our optional dependency has a compatible version
7
- gem 'sequel', Ensql::SEQUEL_VERSION
8
- require 'sequel'
9
+ gem "sequel", Ensql::SUPPORTED_SEQUEL_VERSIONS
10
+ require "sequel"
9
11
 
10
12
  module Ensql
11
13
  #
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.
14
+ # Wraps a Sequel::Database to implement the {Adapter} interface for Sequel.
15
+ # You may want to utilize the relevant Sequel extensions to make the most of
16
+ # database-specific deserialization and other features. By default, uses the
17
+ # first database in Sequel::Databases. Other databases can be passed to the
18
+ # constructor.
16
19
  #
17
- # @example
18
- # require 'sequel'
19
- # DB = Sequel.connect('postgres://localhost/mydb')
20
- # DB.extend(:pg_json)
21
- # Ensql.adapter = Ensql::SequelAdapter
20
+ # require 'sequel'
21
+ # DB = Sequel.connect('postgres://localhost/mydb')
22
+ # DB.extend(:pg_json)
23
+ # Ensql.adapter = Ensql::SequelAdapter.new(DB)
22
24
  #
23
- # @see Adapter
24
- # @see SEQUEL_VERSION Required gem version
25
+ # To stream rows, configure streaming on the connection and use
26
+ # {SQL.each_row}
25
27
  #
26
- module SequelAdapter
27
- extend Adapter
28
+ # DB = Sequel.connect('postgresql:/')
29
+ # DB.extension(:pg_streaming)
30
+ # DB.stream_all_queries = true
31
+ # Ensql.adapter = Ensql::SequelAdapter.new(DB)
32
+ # Ensql.sql("select * from large_table").each_row do |row|
33
+ # # This now yields each row in single-row mode.
34
+ # # The connection cannot be used for other queries while this is streaming.
35
+ # end
36
+ #
37
+ # @see SUPPORTED_SEQUEL_VERSIONS
38
+ #
39
+ class SequelAdapter
40
+ include Adapter
41
+
42
+ # Support deprecated class method interface
43
+ class << self
44
+ require "forwardable"
45
+ extend Forwardable
46
+
47
+ delegate [:literalize, :run, :fetch_count, :fetch_each_row, :fetch_rows, :fetch_first_column, :fetch_first_field, :fetch_first_row] => :new
48
+ end
49
+
50
+ # Wrap the raw connections from a Sequel::Database connection pool. This
51
+ # allows us to safely checkout the underlying database connection for use in
52
+ # a database specific adapter.
53
+ #
54
+ # Ensql.adapter = MySqliteAdapter.new(SequelAdapter.pool)
55
+ #
56
+ # @param db [Sequel::Database]
57
+ # @return [PoolWrapper] a pool adapter for raw connections
58
+ def self.pool(db)
59
+ PoolWrapper.new do |client_block|
60
+ db.pool.hold(&client_block)
61
+ end
62
+ end
28
63
 
29
- # @!visibility private
30
- def self.fetch_rows(sql)
31
- db.fetch(sql).map { |r| r.transform_keys(&:to_s) }
64
+ # @param db [Sequel::Database]
65
+ def initialize(db = first_configured_database)
66
+ @db = db
32
67
  end
33
68
 
34
- # @!visibility private
35
- def self.fetch_count(sql)
69
+ # @visibility private
70
+ def fetch_rows(sql)
71
+ fetch_each_row(sql).to_a
72
+ end
73
+
74
+ # @visibility private
75
+ def fetch_each_row(sql)
76
+ return to_enum(:fetch_each_row, sql) unless block_given?
77
+
78
+ db.fetch(sql) { |r| yield r.transform_keys(&:to_s) }
79
+ end
80
+
81
+ # @visibility private
82
+ def fetch_count(sql)
36
83
  db.execute_dui(sql)
37
84
  end
38
85
 
39
- # @!visibility private
40
- def self.run(sql)
86
+ # @visibility private
87
+ def run(sql)
41
88
  db << sql
89
+ nil
42
90
  end
43
91
 
44
- # @!visibility private
45
- def self.literalize(value)
92
+ # @visibility private
93
+ def literalize(value)
46
94
  db.literal(value)
47
95
  end
48
96
 
49
- def self.db
50
- Sequel::DATABASES.first or raise Error, "no connection found in Sequel::DATABASES"
51
- end
97
+ private
52
98
 
53
- private_class_method :db
99
+ attr_reader :db
54
100
 
101
+ def first_configured_database
102
+ Sequel::DATABASES.first || raise(Error, "no database found in Sequel::DATABASES")
103
+ end
55
104
  end
56
105
  end