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.
- checksums.yaml +4 -4
- data/.github/workflows/lint.yml +52 -0
- data/.github/workflows/specs.yml +59 -0
- data/.yardopts +2 -0
- data/CHANGELOG.md +32 -2
- data/Gemfile +8 -14
- data/Gemfile.lock +10 -3
- data/README.md +124 -64
- data/ensql.gemspec +17 -11
- data/gemfiles/maintained.gemfile +22 -0
- data/gemfiles/maintained.gemfile.lock +81 -0
- data/gemfiles/minimum.gemfile +26 -0
- data/gemfiles/minimum.gemfile.lock +76 -0
- data/lib/ensql.rb +5 -65
- data/lib/ensql/active_record_adapter.rb +77 -33
- data/lib/ensql/adapter.rb +50 -12
- data/lib/ensql/error.rb +6 -0
- data/lib/ensql/load_sql.rb +39 -0
- data/lib/ensql/pool_wrapper.rb +21 -0
- data/lib/ensql/postgres_adapter.rb +177 -0
- data/lib/ensql/sequel_adapter.rb +79 -30
- data/lib/ensql/sql.rb +14 -13
- data/lib/ensql/transaction.rb +57 -0
- data/lib/ensql/version.rb +7 -5
- data/perf/adapter_benchmark.rb +102 -0
- metadata +93 -5
data/lib/ensql/adapter.rb
CHANGED
@@ -1,8 +1,49 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "
|
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
|
-
|
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
|
data/lib/ensql/error.rb
ADDED
@@ -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
|
data/lib/ensql/sequel_adapter.rb
CHANGED
@@ -1,56 +1,105 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative
|
4
|
-
require_relative
|
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
|
8
|
-
require
|
9
|
+
gem "sequel", Ensql::SUPPORTED_SEQUEL_VERSIONS
|
10
|
+
require "sequel"
|
9
11
|
|
10
12
|
module Ensql
|
11
13
|
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
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
|
-
#
|
18
|
-
#
|
19
|
-
#
|
20
|
-
#
|
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
|
-
#
|
24
|
-
#
|
25
|
+
# To stream rows, configure streaming on the connection and use
|
26
|
+
# {SQL.each_row}
|
25
27
|
#
|
26
|
-
|
27
|
-
|
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
|
-
#
|
30
|
-
def
|
31
|
-
db
|
64
|
+
# @param db [Sequel::Database]
|
65
|
+
def initialize(db = first_configured_database)
|
66
|
+
@db = db
|
32
67
|
end
|
33
68
|
|
34
|
-
#
|
35
|
-
def
|
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
|
-
#
|
40
|
-
def
|
86
|
+
# @visibility private
|
87
|
+
def run(sql)
|
41
88
|
db << sql
|
89
|
+
nil
|
42
90
|
end
|
43
91
|
|
44
|
-
#
|
45
|
-
def
|
92
|
+
# @visibility private
|
93
|
+
def literalize(value)
|
46
94
|
db.literal(value)
|
47
95
|
end
|
48
96
|
|
49
|
-
|
50
|
-
Sequel::DATABASES.first or raise Error, "no connection found in Sequel::DATABASES"
|
51
|
-
end
|
97
|
+
private
|
52
98
|
|
53
|
-
|
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
|