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