pg_pipeline 0.3.2 → 0.3.4

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.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "errors"
3
+ require_relative 'errors'
4
4
 
5
5
  module PgPipeline
6
6
  module SessionGuard
@@ -9,27 +9,27 @@ module PgPipeline
9
9
  VALID_MODES = %i[default strict].freeze
10
10
  ALLOWED_LEADING = %w[select insert update delete merge values with].freeze
11
11
  FORBIDDEN_PATTERNS = {
12
- "set_config" => /\bset_config\s*\(/i,
13
- "setseed" => /\bsetseed\s*\(/i,
14
- "currval" => /\bcurrval\s*\(/i,
15
- "lastval" => /\blastval\s*\(/i,
16
- "session-advisory-lock" => /\bpg_(?:try_)?advisory_lock(?:_shared)?\s*\(/i,
17
- "session-advisory-unlock" => /\bpg_advisory_unlock(?:_shared|_all)?\s*\(/i,
18
- "select-into-temp" => /\binto\s+(?:(?:global|local)\s+)?temp(?:orary)?\b/i,
19
- "select-into-pg-temp" => /\binto\s+(?:table\s+)?pg_temp(?:_\d+)?\s*\./i
12
+ 'set_config' => /\bset_config\s*\(/i,
13
+ 'setseed' => /\bsetseed\s*\(/i,
14
+ 'currval' => /\bcurrval\s*\(/i,
15
+ 'lastval' => /\blastval\s*\(/i,
16
+ 'session-advisory-lock' => /\bpg_(?:try_)?advisory_lock(?:_shared)?\s*\(/i,
17
+ 'session-advisory-unlock' => /\bpg_advisory_unlock(?:_shared|_all)?\s*\(/i,
18
+ 'select-into-temp' => /\binto\s+(?:(?:global|local)\s+)?temp(?:orary)?\b/i,
19
+ 'select-into-pg-temp' => /\binto\s+(?:table\s+)?pg_temp(?:_\d+)?\s*\./i
20
20
  }.freeze
21
21
  STRICT_FORBIDDEN = {
22
- "nextval" => /\bnextval\s*\(/i,
23
- "setval" => /\bsetval\s*\(/i,
24
- "setseed" => /\bsetseed\s*\(/i,
25
- "set_config" => /\bset_config\s*\(/i,
26
- "session-advisory-lock" => /\bpg_(?:try_)?advisory_lock(?:_shared)?\s*\(/i,
27
- "session-advisory-unlock" => /\bpg_advisory_unlock(?:_shared|_all)?\s*\(/i,
28
- "pg_export_snapshot" => /\bpg_export_snapshot\s*\(/i
22
+ 'nextval' => /\bnextval\s*\(/i,
23
+ 'setval' => /\bsetval\s*\(/i,
24
+ 'setseed' => /\bsetseed\s*\(/i,
25
+ 'set_config' => /\bset_config\s*\(/i,
26
+ 'session-advisory-lock' => /\bpg_(?:try_)?advisory_lock(?:_shared)?\s*\(/i,
27
+ 'session-advisory-unlock' => /\bpg_advisory_unlock(?:_shared|_all)?\s*\(/i,
28
+ 'pg_export_snapshot' => /\bpg_export_snapshot\s*\(/i
29
29
  }.freeze
30
30
  PATTERN_PREFILTER = /\binto\b/i
31
31
  DOLLAR_QUOTE_TAG = /\A\$(?:(?:[A-Za-z_]|[^\x00-\x7F])(?:[A-Za-z0-9_]|[^\x00-\x7F])*)?\$/
32
- NEEDS_MASK = /['"]|--|\/\*|\$(?:(?:[A-Za-z_]|[^\x00-\x7F])(?:[A-Za-z0-9_]|[^\x00-\x7F])*)?\$/
32
+ NEEDS_MASK = %r{['"]|--|/\*|\$(?:(?:[A-Za-z_]|[^\x00-\x7F])(?:[A-Za-z0-9_]|[^\x00-\x7F])*)?\$}
33
33
  LEADING_KEYWORD = /\A\s*([a-zA-Z_]+)/
34
34
  WHITESPACE_BYTES = [9, 10, 11, 12, 13, 32].freeze
35
35
 
@@ -43,7 +43,7 @@ module PgPipeline
43
43
 
44
44
  raise UnsafeMultiplexError,
45
45
  "refusing to multiplex SQL (#{reason}); the shared path only accepts " \
46
- "session-neutral operations. Use Client#session for session work or " \
46
+ 'session-neutral operations. Use Client#session for session work or ' \
47
47
  "Client#transaction for an explicit transaction.\n" \
48
48
  " offending SQL: #{sql.to_s.strip[0, 160]}"
49
49
  end
@@ -75,20 +75,29 @@ module PgPipeline
75
75
  code = NEEDS_MASK.match?(sql) ? code_only(sql) : sql
76
76
  lead = code[LEADING_KEYWORD, 1]&.downcase
77
77
 
78
- return "empty" unless lead
78
+ return 'empty' unless lead
79
79
  return "leading:#{lead}" unless ALLOWED_LEADING.include?(lead)
80
- return "multiple-statements" if multiple_statements?(code)
81
- return nil unless code.include?("(") || code.match?(PATTERN_PREFILTER)
80
+ return 'multiple-statements' if multiple_statements?(code)
81
+ return nil unless code.include?('(') || code.match?(PATTERN_PREFILTER)
82
82
 
83
- FORBIDDEN_PATTERNS.each do |name, pattern|
83
+ forbidden_reason(code) || strict_forbidden_reason(code, mode)
84
+ end
85
+
86
+ def forbidden_reason(code)
87
+ pattern_reason(code, FORBIDDEN_PATTERNS)
88
+ end
89
+
90
+ def strict_forbidden_reason(code, mode)
91
+ return unless mode == :strict
92
+
93
+ reason = pattern_reason(code, STRICT_FORBIDDEN)
94
+ "strict:#{reason}" if reason
95
+ end
96
+
97
+ def pattern_reason(code, patterns)
98
+ patterns.each do |name, pattern|
84
99
  return name if code.match?(pattern)
85
100
  end
86
- if mode == :strict
87
- STRICT_FORBIDDEN.each do |name, pattern|
88
- return "strict:#{name}" if code.match?(pattern)
89
- end
90
- end
91
-
92
101
  nil
93
102
  end
94
103
 
@@ -96,7 +105,7 @@ module PgPipeline
96
105
  normalized = mode.respond_to?(:to_sym) ? mode.to_sym : mode
97
106
  return normalized if VALID_MODES.include?(normalized)
98
107
 
99
- raise ArgumentError, "guard must be one of: #{VALID_MODES.map(&:inspect).join(", ")}"
108
+ raise ArgumentError, "guard must be one of: #{VALID_MODES.map(&:inspect).join(', ')}"
100
109
  end
101
110
 
102
111
  def code_only(sql)
@@ -104,43 +113,19 @@ module PgPipeline
104
113
  size = source.bytesize
105
114
  output = String.new(capacity: size, encoding: Encoding::BINARY)
106
115
  index = 0
107
- block_depth = 0
108
116
 
109
117
  while index < size
110
118
  byte = source.getbyte(index)
111
- nxt = index + 1 < size ? source.getbyte(index + 1) : nil
112
- if block_depth.positive?
113
- if byte == 47 && nxt == 42
114
- block_depth += 1
115
- output << " "
116
- index += 2
117
- elsif byte == 42 && nxt == 47
118
- block_depth -= 1
119
- output << " "
120
- index += 2
121
- else
122
- output << (byte == 10 ? 10 : 32)
123
- index += 1
124
- end
125
- next
126
- end
119
+ next_index = index + 1
120
+ nxt = next_index < size ? source.getbyte(next_index) : nil
127
121
 
128
122
  if byte == 45 && nxt == 45
129
- newline = source.index("\n", index + 2)
130
- if newline
131
- output << (" " * (newline - index)) << "\n"
132
- index = newline + 1
133
- else
134
- output << (" " * (size - index))
135
- break
136
- end
123
+ index = mask_line_comment(source, output, index)
137
124
  next
138
125
  end
139
126
 
140
127
  if byte == 47 && nxt == 42
141
- block_depth = 1
142
- output << " "
143
- index += 2
128
+ index = mask_block_comment(source, output, index)
144
129
  next
145
130
  end
146
131
 
@@ -155,12 +140,8 @@ module PgPipeline
155
140
  end
156
141
 
157
142
  if byte == 36
158
- remainder = source.byteslice(index, size - index)
159
- tag = DOLLAR_QUOTE_TAG.match(remainder)&.[](0)
160
- if tag
161
- closing = source.index(tag, index + tag.bytesize)
162
- finish = closing ? closing + tag.bytesize : size
163
- output << (" " * (finish - index))
143
+ finish = mask_dollar_quote(source, output, index, size)
144
+ if finish
164
145
  index = finish
165
146
  next
166
147
  end
@@ -173,29 +154,79 @@ module PgPipeline
173
154
  output
174
155
  end
175
156
 
157
+ def mask_line_comment(source, output, index)
158
+ newline = source.index("\n", index + 2)
159
+ if newline
160
+ output << (' ' * (newline - index)) << "\n"
161
+ newline + 1
162
+ else
163
+ output << (' ' * (source.bytesize - index))
164
+ source.bytesize
165
+ end
166
+ end
167
+
168
+ def mask_block_comment(source, output, index)
169
+ depth = 1
170
+ index = append_comment_delimiter(output, index)
171
+
172
+ while index < source.bytesize
173
+ byte = source.getbyte(index)
174
+ nxt = source.getbyte(index + 1)
175
+ if byte == 47 && nxt == 42
176
+ depth += 1
177
+ index = append_comment_delimiter(output, index)
178
+ elsif byte == 42 && nxt == 47
179
+ depth -= 1
180
+ index = append_comment_delimiter(output, index)
181
+ break if depth.zero?
182
+ else
183
+ output << (byte == 10 ? 10 : 32)
184
+ index += 1
185
+ end
186
+ end
187
+
188
+ index
189
+ end
190
+
191
+ def append_comment_delimiter(output, index)
192
+ output << ' '
193
+ index + 2
194
+ end
195
+
196
+ def mask_dollar_quote(source, output, index, size)
197
+ remainder = source.byteslice(index, size - index)
198
+ tag = DOLLAR_QUOTE_TAG.match(remainder)&.[](0)
199
+ return unless tag
200
+
201
+ closing = source.index(tag, index + tag.bytesize)
202
+ finish = closing ? closing + tag.bytesize : size
203
+ output << (' ' * (finish - index))
204
+ finish
205
+ end
206
+
176
207
  def mask_quoted(source, output, index, quote_byte, escape_backslash:)
177
- output << " "
208
+ output << ' '
178
209
  index += 1
179
210
 
180
211
  while index < source.bytesize
181
212
  byte = source.getbyte(index)
182
213
  if escape_backslash && byte == 92
183
- output << " "
214
+ output << ' '
184
215
  index += 1
185
216
  if index < source.bytesize
186
- output << (source.getbyte(index) == 10 ? "\n" : " ")
217
+ output << (source.getbyte(index) == 10 ? "\n" : ' ')
187
218
  index += 1
188
219
  end
189
220
  elsif byte == quote_byte
190
221
  if source.getbyte(index + 1) == quote_byte
191
- output << " "
222
+ output << ' '
192
223
  index += 2
193
224
  else
194
- output << " "
225
+ output << ' '
195
226
  return index + 1
196
227
  end
197
228
  else
198
- output << (byte == 10 ? "\n" : " ")
229
+ output << (byte == 10 ? "\n" : ' ')
199
230
  index += 1
200
231
  end
201
232
  end
@@ -207,15 +238,19 @@ module PgPipeline
207
238
  return false if quote_index.zero?
208
239
 
209
240
  marker = source.getbyte(quote_index - 1)
210
- return false unless marker == 69 || marker == 101
241
+ return false unless [69, 101].include?(marker)
211
242
 
212
243
  before = quote_index >= 2 ? source.getbyte(quote_index - 2) : nil
213
- before.nil? || !(before.between?(48, 57) || before.between?(65, 90) || before.between?(97, 122) || before == 95)
244
+ !sql_identifier_byte?(before)
245
+ end
246
+
247
+ def sql_identifier_byte?(byte)
248
+ byte && (byte.between?(48, 57) || byte.between?(65, 90) || byte.between?(97, 122) || byte == 95)
214
249
  end
215
- private_class_method :escape_string_prefix?
250
+ private_class_method :escape_string_prefix?, :sql_identifier_byte?
216
251
 
217
252
  def multiple_statements?(code)
218
- first = code.index(";")
253
+ first = code.index(';')
219
254
  return false unless first
220
255
 
221
256
  index = first + 1
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pg"
3
+ require 'pg'
4
4
 
5
- require_relative "session"
5
+ require_relative 'session'
6
6
 
7
7
  module PgPipeline
8
8
  class Transaction < Session
@@ -30,68 +30,67 @@ module PgPipeline
30
30
  module TransactionOps
31
31
  module_function
32
32
 
33
- def run(tx)
34
- SessionOps.ensure_active!(tx)
35
- raise Error, "transaction is already open" if tx.open?
33
+ def run(transaction)
34
+ SessionOps.ensure_active!(transaction)
35
+ raise Error, 'transaction is already open' if transaction.open?
36
36
 
37
- conn = SessionOps.connection(tx)
38
- conn.exec("BEGIN")
39
- tx.__send__(:open=, true)
37
+ conn = SessionOps.connection(transaction)
38
+ conn.exec('BEGIN')
39
+ transaction.__send__(:open=, true)
40
40
  begin
41
- result = yield tx
41
+ result = yield transaction
42
42
  commit(conn)
43
- tx.__send__(:open=, false)
43
+ transaction.__send__(:open=, false)
44
44
  result
45
45
  rescue Exception
46
- rollback_quietly(tx)
46
+ rollback_quietly(transaction)
47
47
  raise
48
48
  end
49
49
  end
50
50
 
51
51
  def commit(conn)
52
- conn.exec("COMMIT")
53
- rescue PG::Error => error
54
- raise unless indeterminate_commit_failure?(conn, error)
52
+ conn.exec('COMMIT')
53
+ rescue PG::Error => e
54
+ raise unless indeterminate_commit_failure?(conn, e)
55
55
 
56
56
  raise IndeterminateCommitError.new(
57
- "COMMIT acknowledgement was not received; the transaction may have committed " \
58
- "and must not be retried blindly (#{error.class}: #{error.message})"
59
- ), cause: error
57
+ 'COMMIT acknowledgement was not received; the transaction may have committed ' \
58
+ "and must not be retried blindly (#{e.class}: #{e.message})"
59
+ ), cause: e
60
60
  end
61
61
 
62
62
  def indeterminate_commit_failure?(conn, error)
63
63
  return true if defined?(PG::ConnectionBad) && error.is_a?(PG::ConnectionBad)
64
+ return false unless conn.respond_to?(:finished?) && conn.respond_to?(:status)
64
65
  return true if conn.finished?
65
66
 
66
67
  conn.status != PG::CONNECTION_OK
67
68
  rescue PG::Error
68
69
  true
69
- rescue NoMethodError
70
- false
71
70
  end
72
71
 
73
- def savepoint(tx, name)
74
- SessionOps.ensure_active!(tx)
75
- raise Error, "savepoint requires an open transaction" unless tx.open?
72
+ def savepoint(transaction, name)
73
+ SessionOps.ensure_active!(transaction)
74
+ raise Error, 'savepoint requires an open transaction' unless transaction.open?
76
75
 
77
- conn = SessionOps.connection(tx)
78
- seq = tx.__send__(:savepoint_seq) + 1
79
- tx.__send__(:savepoint_seq=, seq)
76
+ conn = SessionOps.connection(transaction)
77
+ seq = transaction.__send__(:savepoint_seq) + 1
78
+ transaction.__send__(:savepoint_seq=, seq)
80
79
  point = name || "pgp_sp_#{seq}"
81
80
  ident = conn.quote_ident(point)
82
81
  conn.exec("SAVEPOINT #{ident}")
83
82
  begin
84
- result = yield tx
83
+ result = yield transaction
85
84
  conn.exec("RELEASE SAVEPOINT #{ident}")
86
85
  result
87
86
  rescue Exception
88
- rollback_to_savepoint(tx, ident)
87
+ rollback_to_savepoint(transaction, ident)
89
88
  raise
90
89
  end
91
90
  end
92
91
 
93
- def rollback_to_savepoint(tx, ident)
94
- conn = SessionOps.connection(tx)
92
+ def rollback_to_savepoint(transaction, ident)
93
+ conn = SessionOps.connection(transaction)
95
94
  return unless conn
96
95
 
97
96
  conn.exec("ROLLBACK TO SAVEPOINT #{ident}")
@@ -100,15 +99,15 @@ module PgPipeline
100
99
  nil
101
100
  end
102
101
 
103
- def rollback_quietly(tx)
104
- return unless tx.open?
102
+ def rollback_quietly(transaction)
103
+ return unless transaction.open?
105
104
 
106
- conn = SessionOps.connection(tx)
107
- conn&.exec("ROLLBACK")
105
+ conn = SessionOps.connection(transaction)
106
+ conn&.exec('ROLLBACK')
108
107
  rescue PG::Error
109
108
  nil
110
109
  ensure
111
- tx.__send__(:open=, false)
110
+ transaction.__send__(:open=, false)
112
111
  end
113
112
  end
114
113
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pg'
4
+ require_relative 'errors'
5
+
6
+ module PgPipeline
7
+ class TypeMaps
8
+ PENDING = Object.new.freeze
9
+
10
+ def initialize
11
+ @cache = {}
12
+ @wants_typed = {}
13
+ @bundle = nil
14
+ @text_map_for_results = nil
15
+ end
16
+
17
+ def register(statement)
18
+ @wants_typed[statement.physical_name] = true if statement.typed?
19
+ end
20
+
21
+ def unregister(physical_name)
22
+ @cache.delete(physical_name)
23
+ @wants_typed.delete(physical_name)
24
+ end
25
+
26
+ def typed?(physical_name)
27
+ @wants_typed[physical_name]
28
+ end
29
+
30
+ def cached(physical_name)
31
+ @cache[physical_name]
32
+ end
33
+
34
+ def bind(request, statement)
35
+ return unless statement.typed?
36
+
37
+ request.type_map = @cache[statement.physical_name] || PENDING
38
+ end
39
+
40
+ def ensure_bundle!(connection_args)
41
+ return if @bundle && @bundle != :failed
42
+
43
+ build_bundle(connection_args)
44
+ rescue StandardError => e
45
+ @bundle = :failed
46
+ @text_map_for_results = nil
47
+ raise Error, "failed to build CoderMapsBundle (#{e.class}: #{e.message})"
48
+ end
49
+
50
+ private
51
+
52
+ def build_bundle(connection_args)
53
+ conn = open_connection(connection_args)
54
+ @bundle = PG::BasicTypeRegistry::CoderMapsBundle.new(conn)
55
+ @text_map_for_results = PG::BasicTypeMapForResults.new(@bundle)
56
+ ensure
57
+ close_connection(conn)
58
+ end
59
+
60
+ def open_connection(connection_args)
61
+ return PG::Connection.new if connection_args.nil?
62
+
63
+ PG::Connection.new(connection_args)
64
+ end
65
+
66
+ def close_connection(conn)
67
+ conn&.close
68
+ rescue StandardError
69
+ nil
70
+ end
71
+
72
+ public
73
+
74
+ def apply!(request, result, _conn)
75
+ map = request.type_map
76
+ return if map.nil?
77
+
78
+ map = resolve_type_map(request, result, map)
79
+ request.type_map = map if request.type_map.equal?(PENDING)
80
+
81
+ result.type_map = map
82
+ end
83
+
84
+ private
85
+
86
+ def resolve_type_map(request, result, map)
87
+ return map unless map.equal?(PENDING)
88
+
89
+ name = request.statement_name
90
+ @cache[name] ||= build_column_map!(result)
91
+ end
92
+
93
+ def build_column_map!(result)
94
+ unless result.result_status == PG::PGRES_TUPLES_OK && result.nfields.positive?
95
+ raise Error, 'typed mapping requires a TUPLES_OK result with at least one column'
96
+ end
97
+ if @bundle == :failed || @text_map_for_results.nil?
98
+ raise Error, 'typed result mapping is unavailable (CoderMapsBundle failed)'
99
+ end
100
+
101
+ @text_map_for_results.build_column_map(result)
102
+ end
103
+ end
104
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgPipeline
4
- VERSION = "0.3.2"
4
+ VERSION = '0.3.4'
5
5
  end
data/lib/pg_pipeline.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "pg_pipeline/session"
9
9
  require_relative "pg_pipeline/transaction"
10
10
  require_relative "pg_pipeline/request"
11
11
  require_relative "pg_pipeline/prepared_statement"
12
+ require_relative "pg_pipeline/type_maps"
12
13
  require_relative "pg_pipeline/connection_driver"
13
14
  require_relative "pg_pipeline/pool"
14
15
  require_relative "pg_pipeline/client"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_pipeline
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov
@@ -134,6 +134,7 @@ files:
134
134
  - lib/pg_pipeline/session.rb
135
135
  - lib/pg_pipeline/session_guard.rb
136
136
  - lib/pg_pipeline/transaction.rb
137
+ - lib/pg_pipeline/type_maps.rb
137
138
  - lib/pg_pipeline/version.rb
138
139
  homepage: https://github.com/roman-haidarov/pg_pipeline
139
140
  licenses: