ensql 0.6.0 → 0.6.5

Sign up to get free protection for your applications and to get access to all the features.
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