pg_pipeline 0.3.2 → 0.3.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7fdda2e5d806be070a3255e8cef4a63b72acaeac87d7a7777d580bfb0fc0d0a
4
- data.tar.gz: 2b23bf8808241eb64396672959a86ae86c26af05bb0d72049de646907cc9e99d
3
+ metadata.gz: f3bb4583fbf47130e8b86eec8f44c70577f053c9cacd1e9774a09a5b7a5d3999
4
+ data.tar.gz: '099f44877fde9db5faa63a776e31ecb6d1b56cc155dd08fd0b6dff52252a719e'
5
5
  SHA512:
6
- metadata.gz: 37a5b065506fb61a675a72023057b0d24254ea7fa411ea358c68e255e0ffabf53079ab3a7214be59b2d09b02063aa2c09875576868a213df8c91526f90834ebd
7
- data.tar.gz: fb0e3b15f73e8de66b56d09c7791b8e357477c859b83293474ec48c15d41d12298f91c974bd8ebeb0ae2cc6a3437ed598c442e6eb26e8a13787e71a3d35856f7
6
+ metadata.gz: ddc6fa628e441b5cf744a3521e0ca90a923cf732be59662c6cee9df5f522ad69319628d6ee98e18711baa28d8d42eef7585c0af6239cb9a8f4f921212338d014
7
+ data.tar.gz: 75e56315d776d60cadf07b675e048fdb674f06bc8b67fe960e95be4d7ed8bd89008eb25a8828eb96655994ed83fa23f392a541a73b77dbc4f113dc28261c815a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.3] - 2026-08-19
4
+
5
+ Opt-in typed result decoding for multiplexed prepared statements. Default
6
+ behaviour, Sync-per-unit, and the failure model are unchanged.
7
+
8
+ ### Added
9
+
10
+ - `Client#prepare(..., typed: true)` — text format. The first `TUPLES_OK`
11
+ result builds a `TypeMapByColumn`; later dispatches reuse that map from the
12
+ `Request` (resolved at submit, never re-read from handle state at drain).
13
+ Column values arrive as typed Ruby objects (Integer, Time, BigDecimal, …)
14
+ instead of strings, for every PostgreSQL type ruby-pg covers in text mode.
15
+
16
+ ### Notes
17
+
18
+ - This is an API feature, not a throughput lever. Typed columns that become
19
+ immediates (`int`, `bool`) drop one heap object each; the rest of the
20
+ per-query allocation is the control plane (`Request`, fibers, `PG::Result`).
21
+ - `CoderMapsBundle` is built on a throwaway connection at the first
22
+ `prepare(..., typed: true)`, never at `Client.start` and never on a live
23
+ pipeline FIFO (a catalog query there desyncs Sync). Untyped `query` /
24
+ `prepare` do not touch type maps on the drain path.
25
+ - A failed type-map apply is a request-local `QueryError`. `typed: true`
26
+ promised a typed value; the handle does not silently fall back to strings.
27
+ - `bigdecimal` is an optional dependency of ruby-pg for `numeric` text
28
+ decoding (required as a gem on Ruby 3.4+). Without it, other typed columns
29
+ still work; `numeric` follows the registry.
30
+ - Default path (no `typed:`): `TypeMapAllStrings`, text format, string keys.
31
+ - Binary result format is not part of this release.
32
+
3
33
  ## [0.3.2] - 2026-08-17
4
34
 
5
35
  Runtime correctness. Public SQL, Sync-per-unit, failure model, and the
data/README.md CHANGED
@@ -143,6 +143,27 @@ ensure
143
143
  end
144
144
  ```
145
145
 
146
+ Pass `typed: true` to decode cells through ruby-pg's text type map (`Integer`,
147
+ `Time`, `BigDecimal`, …) instead of strings. The map is built from the first
148
+ result and reused. This does not change the wire format or the Sync-per-unit
149
+ model; it is opt-in convenience, not a faster path.
150
+
151
+ ```ruby
152
+ by_id = db.prepare(
153
+ "user_by_id",
154
+ "SELECT id, email FROM users WHERE id = $1",
155
+ [23],
156
+ typed: true
157
+ )
158
+
159
+ result = by_id.query([42])
160
+ begin
161
+ result.first["id"] # => 42 (Integer)
162
+ ensure
163
+ result.clear
164
+ end
165
+ ```
166
+
146
167
  `Client#prepare` returns only after the statement is ready on every currently
147
168
  live pipeline connection. Replacement connections automatically prepare the
148
169
  registered catalog before they begin accepting requests. The logical name is
@@ -26,7 +26,9 @@ module PgPipeline
26
26
 
27
27
  def start = ClientOps.start(self)
28
28
  def query(sql, params = RequestOps::EMPTY_PARAMS) = ClientOps.query(self, sql, params)
29
- def prepare(name, sql, param_types = nil) = ClientOps.prepare(self, name, sql, param_types)
29
+ def prepare(name, sql, param_types = nil, typed: false)
30
+ ClientOps.prepare(self, name, sql, param_types, typed: typed)
31
+ end
30
32
  def stats = ClientOps.stats(self)
31
33
 
32
34
  def session(&block)
@@ -78,18 +80,22 @@ module PgPipeline
78
80
  end
79
81
  end
80
82
 
81
- def prepare(client, name, sql, param_types)
83
+ def prepare(client, name, sql, param_types, typed: false)
82
84
  ensure_started!(client)
83
85
  sql = RequestOps.snapshot_sql(sql)
84
86
  SessionGuard.assert_multiplexable_normalized!(sql, mode: client.guard)
85
- pool(client).__send__(:prepare_statement, client, name, sql, param_types)
87
+ pool(client).__send__(:prepare_statement, client, name, sql, param_types, typed: typed)
86
88
  end
87
89
 
88
90
  def query_prepared(client, statement, params)
89
91
  ensure_started!(client)
90
92
 
91
93
  wait_for_request do
92
- submit_with_failover(client) { Request.prepared_query(statement, params: params) }
94
+ submit_with_failover(client) do
95
+ request = Request.prepared_query(statement, params: params)
96
+ pool(client).bind_type_map(request, statement) if statement.typed?
97
+ request
98
+ end
93
99
  end
94
100
  end
95
101
 
@@ -13,7 +13,7 @@ module PgPipeline
13
13
  DEFAULT_MAX_PENDING = 256
14
14
  DEFAULT_MAX_IN_FLIGHT = 64
15
15
 
16
- attr_reader :conn, :caps, :max_pending, :max_in_flight
16
+ attr_reader :conn, :caps, :max_pending, :max_in_flight, :type_maps
17
17
  attr_accessor :socket, :requests, :events, :reader_rearm, :writer_commands,
18
18
  :inflight, :dispatching, :submitting, :accepting, :running,
19
19
  :draining, :needs_flush, :writer_armed, :request_event_pending,
@@ -30,11 +30,13 @@ module PgPipeline
30
30
  def dispatches = @dispatches || 0
31
31
  def leaked_watchers = @leaked_watchers || 0
32
32
 
33
- def initialize(conn, max_pending: DEFAULT_MAX_PENDING, max_in_flight: DEFAULT_MAX_IN_FLIGHT)
33
+ def initialize(conn, max_pending: DEFAULT_MAX_PENDING, max_in_flight: DEFAULT_MAX_IN_FLIGHT,
34
+ type_maps: nil)
34
35
  @max_pending = DriverOps.positive_integer!(max_pending, :max_pending)
35
36
  @max_in_flight = DriverOps.positive_integer!(max_in_flight, :max_in_flight)
36
37
 
37
38
  @conn = conn
39
+ @type_maps = type_maps
38
40
  @caps = ServerCaps.from_connection(conn)
39
41
  @caps.assert_supported!
40
42
  DriverOps.warn_flush_coupling_once(@caps)
@@ -387,45 +389,61 @@ module PgPipeline
387
389
  request = d.inflight.first
388
390
  raise ProtocolError, "result without an in-flight request" unless request
389
391
 
390
- if result.nil?
391
- request.query_boundary!
392
- next
393
- end
394
-
395
- status = result.result_status
396
- case status
397
- when PG::PGRES_TUPLES_OK
398
- ensure_before_query_boundary!(request, status)
399
- request.accept_result(result)
400
- when PG::PGRES_PIPELINE_SYNC
401
- clear_result(result)
402
- complete_front(d, request)
403
- when PG::PGRES_COMMAND_OK, PG::PGRES_EMPTY_QUERY
404
- ensure_before_query_boundary!(request, status)
405
- request.accept_result(result)
406
- when PG::PGRES_FATAL_ERROR
407
- ensure_before_query_boundary!(request, status)
408
- request.record_error!(query_error(result), result: result)
409
- when PG::PGRES_PIPELINE_ABORTED
410
- ensure_before_query_boundary!(request, status)
411
- clear_result(result)
412
- request.record_error!(PipelineAbortedError.new("pipeline unit aborted"))
413
- when PG::PGRES_BAD_RESPONSE
414
- clear_result(result)
415
- raise ProtocolError, "server response was not understood"
416
- when PG::PGRES_COPY_IN, PG::PGRES_COPY_OUT, PG::PGRES_COPY_BOTH
417
- clear_result(result)
418
- raise ProtocolError, "COPY is not supported on the multiplexed pipeline"
419
- else
420
- clear_result(result)
421
- raise ProtocolError, "unexpected pipeline result status #{status}"
422
- end
392
+ handle_pipeline_result(d, request, result)
423
393
  end
424
394
  ensure
425
395
  d.results_read += read if read.positive?
426
396
  end
427
397
  end
428
398
 
399
+ def handle_pipeline_result(d, request, result)
400
+ if result.nil?
401
+ request.query_boundary!
402
+ return
403
+ end
404
+
405
+ status = result.result_status
406
+ case status
407
+ when PG::PGRES_TUPLES_OK
408
+ accept_tuples_ok(d, request, result)
409
+ when PG::PGRES_PIPELINE_SYNC
410
+ clear_result(result)
411
+ complete_front(d, request)
412
+ when PG::PGRES_COMMAND_OK, PG::PGRES_EMPTY_QUERY
413
+ ensure_before_query_boundary!(request, status)
414
+ request.accept_result(result)
415
+ when PG::PGRES_FATAL_ERROR
416
+ ensure_before_query_boundary!(request, status)
417
+ request.record_error!(query_error(result), result: result)
418
+ when PG::PGRES_PIPELINE_ABORTED
419
+ ensure_before_query_boundary!(request, status)
420
+ clear_result(result)
421
+ request.record_error!(PipelineAbortedError.new("pipeline unit aborted"))
422
+ when PG::PGRES_BAD_RESPONSE
423
+ reject_pipeline_status!(result, "server response was not understood")
424
+ when PG::PGRES_COPY_IN, PG::PGRES_COPY_OUT, PG::PGRES_COPY_BOTH
425
+ reject_pipeline_status!(result, "COPY is not supported on the multiplexed pipeline")
426
+ else
427
+ reject_pipeline_status!(result, "unexpected pipeline result status #{status}")
428
+ end
429
+ end
430
+
431
+ def accept_tuples_ok(d, request, result)
432
+ ensure_before_query_boundary!(request, PG::PGRES_TUPLES_OK)
433
+ begin
434
+ apply_type_map(d, request, result)
435
+ request.accept_result(result)
436
+ rescue StandardError => e
437
+ message = e.is_a?(Error) ? e.message : "typed result mapping failed: #{e.class}: #{e.message}"
438
+ request.record_error!(QueryError.new(message, cause_result: result), result: result)
439
+ end
440
+ end
441
+
442
+ def reject_pipeline_status!(result, message)
443
+ clear_result(result)
444
+ raise ProtocolError, message
445
+ end
446
+
429
447
  def ensure_before_query_boundary!(request, status)
430
448
  return unless request.query_boundary_seen?
431
449
 
@@ -644,6 +662,15 @@ module PgPipeline
644
662
  nil
645
663
  end
646
664
 
665
+ def apply_type_map(d, request, result)
666
+ return unless request.type_map
667
+
668
+ maps = d.type_maps
669
+ raise Error, "typed statement has no type-map registry" unless maps
670
+
671
+ maps.apply!(request, result, d.conn)
672
+ end
673
+
647
674
  def clear_result(result)
648
675
  result.clear if result.respond_to?(:clear)
649
676
  end
@@ -6,6 +6,7 @@ require_relative "errors"
6
6
  require_relative "runtime"
7
7
  require_relative "connection_driver"
8
8
  require_relative "prepared_statement"
9
+ require_relative "type_maps"
9
10
  require_relative "server_caps"
10
11
 
11
12
  module PgPipeline
@@ -64,6 +65,7 @@ module PgPipeline
64
65
  @prepared_statements = {}
65
66
  @prepared_generation = 0
66
67
  @statement_sequence = 0
68
+ @type_maps = TypeMaps.new
67
69
 
68
70
  @pinned_free = []
69
71
  @pinned_in_use = {}
@@ -113,59 +115,90 @@ module PgPipeline
113
115
  driver
114
116
  end
115
117
 
116
- def prepare_statement(client, name, sql, param_types)
118
+ def bind_type_map(request, statement)
119
+ return unless statement.typed?
120
+
121
+ maps = @type_maps
122
+ return unless maps
123
+
124
+ maps.bind(request, statement)
125
+ end
126
+
127
+ def prepare_statement(client, name, sql, param_types, typed: false)
117
128
  ensure_available!
118
129
  logical_name = PreparedStatementOps.snapshot_name(name)
119
130
  if @prepared_statements.key?(logical_name)
120
131
  raise Error, "prepared statement #{logical_name.inspect} already exists"
121
132
  end
122
133
 
134
+ statement = register_prepared_statement(client, logical_name, sql, param_types, typed)
135
+ requests = []
136
+
137
+ begin
138
+ requests = dispatch_prepare(statement)
139
+ await_prepares!(requests)
140
+ statement
141
+ rescue Exception
142
+ drop_prepared_statement(logical_name, statement)
143
+ raise
144
+ ensure
145
+ requests.each { |request| request.cancel! unless request.settled? }
146
+ end
147
+ end
148
+
149
+ def register_prepared_statement(client, logical_name, sql, param_types, typed)
123
150
  @statement_sequence += 1
124
151
  statement = PreparedStatement.new(
125
152
  client: client,
126
153
  name: logical_name,
127
154
  physical_name: "pgp_#{@statement_sequence.to_s(36)}",
128
155
  sql: sql,
129
- param_types: param_types
156
+ param_types: param_types,
157
+ typed: typed
130
158
  )
131
159
 
132
160
  @prepared_statements[logical_name] = statement
161
+ register_typed_maps(statement) if statement.typed?
133
162
  @prepared_generation += 1
134
- requests = []
163
+ statement
164
+ end
135
165
 
136
- begin
137
- drivers = @drivers.select(&:available?)
138
- if drivers.empty?
139
- raise NotDispatchedError, "no live pipeline connections; statement was not prepared"
140
- end
166
+ def register_typed_maps(statement)
167
+ @type_maps ||= TypeMaps.new
168
+ @type_maps.register(statement)
169
+ @type_maps.ensure_bundle!(@connection_args)
170
+ end
141
171
 
142
- requests = drivers.map do |driver|
143
- Request.prepare(statement).tap { |request| driver.submit(request) }
144
- end
172
+ def dispatch_prepare(statement)
173
+ drivers = @drivers.select(&:available?)
174
+ if drivers.empty?
175
+ raise NotDispatchedError, "no live pipeline connections; statement was not prepared"
176
+ end
145
177
 
146
- first_error = nil
147
- requests.each do |request|
148
- begin
149
- RequestOps.clear_result(request.wait)
150
- rescue StandardError => e
151
- if first_error
152
- e.clear_result! if e.respond_to?(:clear_result!)
153
- else
154
- first_error = e
155
- end
156
- end
157
- end
158
- raise first_error if first_error
178
+ drivers.map { |driver| Request.prepare(statement).tap { |r| driver.submit(r) } }
179
+ end
159
180
 
160
- statement
161
- rescue Exception
162
- if @prepared_statements.delete(logical_name)
163
- @prepared_generation += 1
181
+ def await_prepares!(requests)
182
+ first_error = nil
183
+ requests.each do |request|
184
+ begin
185
+ RequestOps.clear_result(request.wait)
186
+ rescue StandardError => e
187
+ if first_error
188
+ e.clear_result! if e.respond_to?(:clear_result!)
189
+ else
190
+ first_error = e
191
+ end
164
192
  end
165
- raise
166
- ensure
167
- requests.each { |request| request.cancel! unless request.settled? }
168
193
  end
194
+ raise first_error if first_error
195
+ end
196
+
197
+ def drop_prepared_statement(logical_name, statement)
198
+ return unless @prepared_statements.delete(logical_name)
199
+
200
+ @type_maps.unregister(statement.physical_name) if statement&.typed? && @type_maps
201
+ @prepared_generation += 1
169
202
  end
170
203
 
171
204
  def with_pinned
@@ -174,31 +207,11 @@ module PgPipeline
174
207
  raise Error, "pinned pool is disabled (pinned_size=0)" if @pinned_size.zero?
175
208
 
176
209
  owner = Fiber.current
177
- if @pinned_owners[owner].positive?
178
- raise RecursiveCheckoutError,
179
- "nested Client#session/transaction on the same fiber is not allowed; " \
180
- "use Transaction#savepoint for nested atomicity"
181
- end
210
+ assert_not_nested_pinned!(owner)
182
211
 
183
212
  @pinned_gate.acquire do
184
- raise @pinned_error if @pinned_error
185
- raise ShutdownError, "pool is closing" if @closing
186
-
187
- conn = nil
188
- @pinned_active += 1
189
- @pinned_owners[owner] += 1
190
-
191
- begin
192
- conn = @pinned_free.pop || PoolOps.new_connection(@connection_args)
193
- raise @pinned_error if @pinned_error
194
- raise ShutdownError, "pool is closing" if @closing
195
-
196
- @pinned_in_use[conn] = owner
197
- yield conn
198
- ensure
199
- @pinned_in_use.delete(conn) if conn
200
- release_pinned(owner, conn)
201
- end
213
+ assert_pinned_open!
214
+ run_pinned(owner) { |conn| yield conn }
202
215
  end
203
216
  end
204
217
 
@@ -453,7 +466,7 @@ module PgPipeline
453
466
  def start_pipeline_driver
454
467
  conn = PoolOps.new_connection(@connection_args)
455
468
  prepare_registered_statements(conn)
456
- driver = ConnectionDriver.new(conn, max_pending: @max_pending, max_in_flight: @max_in_flight)
469
+ driver = ConnectionDriver.new(conn, max_pending: @max_pending, max_in_flight: @max_in_flight, type_maps: @type_maps)
457
470
  driver.start
458
471
  rescue Exception
459
472
  PoolOps.safe_close(conn) if conn
@@ -471,10 +484,11 @@ module PgPipeline
471
484
  next if prepared.key?(statement.physical_name)
472
485
 
473
486
  result = if statement.param_types.nil?
474
- conn.prepare(statement.physical_name, statement.sql)
475
- else
476
- conn.prepare(statement.physical_name, statement.sql, statement.param_types)
477
- end
487
+ conn.prepare(statement.physical_name, statement.sql)
488
+ else
489
+ conn.prepare(statement.physical_name, statement.sql, statement.param_types)
490
+ end
491
+
478
492
  RequestOps.clear_result(result)
479
493
  prepared[statement.physical_name] = true
480
494
  end
@@ -483,22 +497,41 @@ module PgPipeline
483
497
  end
484
498
  end
485
499
 
486
- def release_pinned(owner, conn)
487
- if conn
488
- if @closing
489
- PoolOps.safe_close(conn)
490
- else
491
- recycled = recycle_pinned_connection(conn)
492
-
493
- if recycled
494
- if @closing
495
- PoolOps.safe_close(recycled)
496
- else
497
- @pinned_free << recycled
498
- end
499
- end
500
- end
500
+ def assert_not_nested_pinned!(owner)
501
+ return unless @pinned_owners[owner].positive?
502
+
503
+ raise RecursiveCheckoutError,
504
+ "nested Client#session/transaction on the same fiber is not allowed; " \
505
+ "use Transaction#savepoint for nested atomicity"
506
+ end
507
+
508
+ def assert_pinned_open!
509
+ raise @pinned_error if @pinned_error
510
+ raise ShutdownError, "pool is closing" if @closing
511
+ end
512
+
513
+ def run_pinned(owner)
514
+ conn = nil
515
+ @pinned_active += 1
516
+ @pinned_owners[owner] += 1
517
+
518
+ begin
519
+ conn = take_pinned_connection
520
+ assert_pinned_open!
521
+ @pinned_in_use[conn] = owner
522
+ yield conn
523
+ ensure
524
+ @pinned_in_use.delete(conn) if conn
525
+ release_pinned(owner, conn)
501
526
  end
527
+ end
528
+
529
+ def take_pinned_connection
530
+ @pinned_free.pop || PoolOps.new_connection(@connection_args)
531
+ end
532
+
533
+ def release_pinned(owner, conn)
534
+ return_pinned_connection(conn) if conn
502
535
  ensure
503
536
  @pinned_active -= 1
504
537
  @pinned_owners[owner] -= 1
@@ -506,6 +539,22 @@ module PgPipeline
506
539
  @pinned_idle.signal if @pinned_active.zero?
507
540
  end
508
541
 
542
+ def return_pinned_connection(conn)
543
+ if @closing
544
+ PoolOps.safe_close(conn)
545
+ return
546
+ end
547
+
548
+ recycled = recycle_pinned_connection(conn)
549
+ return unless recycled
550
+
551
+ if @closing
552
+ PoolOps.safe_close(recycled)
553
+ else
554
+ @pinned_free << recycled
555
+ end
556
+ end
557
+
509
558
  def recycle_pinned_connection(conn)
510
559
  if conn.server_version < DISCARD_SEQUENCES_SERVER_VERSION
511
560
  PoolOps.safe_close(conn)
@@ -602,9 +651,7 @@ module PgPipeline
602
651
 
603
652
  start = rr % size
604
653
  best = nil
605
- best_index = 0
606
- best_load = 0
607
- offset = 0
654
+ best_index, best_load, offset = 0, 0, 0
608
655
 
609
656
  while offset < size
610
657
  index = start + offset
@@ -5,22 +5,26 @@ require_relative "request"
5
5
 
6
6
  module PgPipeline
7
7
  class PreparedStatement
8
- attr_reader :name, :sql, :param_types, :physical_name
8
+ attr_reader :name, :sql, :param_types, :physical_name, :typed
9
9
 
10
- def initialize(client:, name:, physical_name:, sql:, param_types: nil)
10
+ def initialize(client:, name:, physical_name:, sql:, param_types: nil, typed: false)
11
11
  @client = client
12
12
  @name = PreparedStatementOps.snapshot_name(name)
13
13
  @physical_name = PreparedStatementOps.snapshot_name(physical_name)
14
14
  @sql = RequestOps.snapshot_sql(sql)
15
15
  @param_types = RequestOps.snapshot_param_types(param_types)
16
+ @typed = !!typed
16
17
  freeze
17
18
  end
18
19
 
20
+ def typed? = @typed
21
+
19
22
  def query(params = RequestOps::EMPTY_PARAMS) = PreparedStatementOps.query(self, params)
20
23
  alias call query
21
24
 
22
25
  def inspect
23
- "#<#{self.class} name=#{@name.inspect} sql=#{@sql.inspect}>"
26
+ flag = @typed ? " typed" : ""
27
+ "#<#{self.class} name=#{@name.inspect} sql=#{@sql.inspect}#{flag}>"
24
28
  end
25
29
 
26
30
  private
@@ -42,6 +46,5 @@ module PgPipeline
42
46
 
43
47
  value.frozen? ? value : value.dup.freeze
44
48
  end
45
-
46
49
  end
47
50
  end
@@ -6,7 +6,7 @@ module PgPipeline
6
6
  class Request
7
7
  attr_reader :sql, :params
8
8
  attr_accessor :state, :cancelled, :settled, :result_seen, :query_boundary_seen,
9
- :result, :error, :waiter, :waiter_scheduler
9
+ :result, :error, :waiter, :waiter_scheduler, :type_map
10
10
 
11
11
  def initialize(sql:, params: nil)
12
12
  @sql = RequestOps.snapshot_sql(sql)
@@ -20,6 +20,7 @@ module PgPipeline
20
20
  @error = nil
21
21
  @waiter = nil
22
22
  @waiter_scheduler = nil
23
+ @type_map = nil
23
24
  end
24
25
 
25
26
  def self.build(sql, params)
@@ -64,6 +65,7 @@ module PgPipeline
64
65
  @error = nil
65
66
  @waiter = nil
66
67
  @waiter_scheduler = nil
68
+ @type_map = nil
67
69
  self
68
70
  end
69
71
  end
@@ -0,0 +1,89 @@
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
+ conn = nil
44
+ conn = connection_args.nil? ? PG::Connection.new : PG::Connection.new(connection_args)
45
+ @bundle = PG::BasicTypeRegistry::CoderMapsBundle.new(conn)
46
+ @text_map_for_results = PG::BasicTypeMapForResults.new(@bundle)
47
+ rescue StandardError => e
48
+ @bundle = :failed
49
+ @text_map_for_results = nil
50
+ raise Error, "failed to build CoderMapsBundle (#{e.class}: #{e.message})"
51
+ ensure
52
+ begin
53
+ conn&.close
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ end
58
+
59
+ def apply!(request, result, _conn)
60
+ map = request.type_map
61
+ return if map.nil?
62
+
63
+ if map.equal?(PENDING)
64
+ name = request.statement_name
65
+ map = @cache[name]
66
+ unless map
67
+ map = build_column_map!(result)
68
+ @cache[name] = map
69
+ end
70
+ request.type_map = map
71
+ end
72
+
73
+ result.type_map = map
74
+ end
75
+
76
+ private
77
+
78
+ def build_column_map!(result)
79
+ unless result.result_status == PG::PGRES_TUPLES_OK && result.nfields.positive?
80
+ raise Error, "typed mapping requires a TUPLES_OK result with at least one column"
81
+ end
82
+ if @bundle == :failed || @text_map_for_results.nil?
83
+ raise Error, "typed result mapping is unavailable (CoderMapsBundle failed)"
84
+ end
85
+
86
+ @text_map_for_results.build_column_map(result)
87
+ end
88
+ end
89
+ 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.3"
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.3
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: