pgoutput-source-adapter 0.2.0 → 0.3.0

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: 96f17f6324e61545be066327df21f021a9b00a1c8a77f5868d74f87ec716e5f2
4
- data.tar.gz: e56b9ff8c51ca0952acf5ce00663c9fd406caf9a1e1c9439f6f58caf19c28d41
3
+ metadata.gz: 96fa814d19258cf12c3f2d6398f72f21f1a2dfab59e89562b880f26fbb7bf1cd
4
+ data.tar.gz: 300f6a751391ee90ba84dfdbe743839e605fb6655aafc5057f9b87a1e25c3faf
5
5
  SHA512:
6
- metadata.gz: 8c4868ad4effe15ace70dae52991e7c45f64116cbe6ea613df77d494bdfd9486a9bbcbcabcd31873d5e474655edfde50588563e498f2a11ef0e9bf9d8ab1e76e
7
- data.tar.gz: de92c820e593d5d5f05e41885f002333df1aa0d16d00af1f5c4d5f05df8f8655571d8261cf05192a7807830d263db65e6e94908af61c786efd9b1a2a01053443
6
+ metadata.gz: 314c92d8ec4d3853056f4c69d99eefb6794aaefbc58a1b5f9e0b2de25c372cce1e8c4864210ade04400d23feb60fc47d68d535c1df901d7ef60a373bc4c735b8
7
+ data.tar.gz: 88c5f21bc01667b196a1b347e176cca6aa7caa655a058fa1550bdee3bcb3b88c511fd9371225f5a95fe585dac572a219b46a14a7135e956c97ac47fd8466e986
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-07-17
4
+
5
+ ### Added
6
+
7
+ - Added `ReplicaIdentityResolver` for schema-aware non-`id` and composite keys,
8
+ keyed by relation id, schema/table pair, or qualified relation name.
9
+ - Added default extraction from decoded relation key-column metadata when
10
+ events expose replica-identity columns or flagged relation columns.
11
+
12
+ ### Fixed
13
+
14
+ - Preserved complete composite and non-`id` old-key tuples for normalized
15
+ UPDATE and DELETE events.
16
+ - Fail closed when configured replica-key columns are absent instead of
17
+ emitting an incomplete `ChangeEvent#primary_key`.
18
+
19
+ ### Documentation
20
+
21
+ - Documented relation metadata, schema-aware resolver configuration, and the
22
+ legacy `id` compatibility fallback.
23
+
3
24
  ## [0.2.0] - 2026-07-17
4
25
 
5
26
  ### Added
data/README.md CHANGED
@@ -107,18 +107,35 @@ normalization path for finite batches.
107
107
 
108
108
  ## Primary keys
109
109
 
110
- For update and delete events, pgoutput may provide an old-key tuple. When it does, that tuple is used as the `CDC::Core::ChangeEvent#primary_key`.
110
+ For update and delete events, pgoutput may provide an old-key tuple. When it
111
+ does, the complete tuple is used as `CDC::Core::ChangeEvent#primary_key`,
112
+ including composite and non-`id` replica keys.
111
113
 
112
- For insert events, or for sources without old-key tuples, the adapter defaults to `id` / `"id"` when present.
114
+ For inserts and events without an old-key tuple, the default resolver uses
115
+ relation key-column metadata when the decoded event exposes
116
+ `replica_identity_columns`, `key_columns`, or a relation whose column flags mark
117
+ replica-key columns. The adapter extracts every declared column and raises
118
+ `Pgoutput::SourceAdapter::Error` rather than returning a partial key.
113
119
 
114
- You can provide your own resolver:
120
+ When decoded events do not carry relation key metadata, configure the reusable
121
+ schema-aware resolver. Relations may be keyed by relation id,
122
+ `[schema, table]`, or a qualified name:
115
123
 
116
124
  ```ruby
125
+ resolver = Pgoutput::SourceAdapter::ReplicaIdentityResolver.new(
126
+ ["public", "memberships"] => ["tenant_id", "member_uuid"],
127
+ "public.accounts" => ["account_uuid"]
128
+ )
129
+
117
130
  adapter = Pgoutput::SourceAdapter::Cdc.new(
118
- primary_key_resolver: ->(_event, values) { { "uuid" => values.fetch("uuid") } }
131
+ primary_key_resolver: resolver
119
132
  )
120
133
  ```
121
134
 
135
+ The legacy `id` / `"id"` inference remains as a compatibility fallback only
136
+ when neither relation metadata nor an explicit resolver is available. Custom
137
+ callables remain supported through `primary_key_resolver:`.
138
+
122
139
  ## Metadata
123
140
 
124
141
  Each normalized event includes pgoutput metadata:
@@ -144,6 +161,7 @@ adapter = Pgoutput::SourceAdapter::Cdc.new(
144
161
  ```ruby
145
162
  Pgoutput::SourceAdapter
146
163
  Pgoutput::SourceAdapter::Cdc
164
+ Pgoutput::SourceAdapter::ReplicaIdentityResolver
147
165
  ```
148
166
 
149
167
  A compatibility alias is also provided for the generated gem path:
@@ -274,7 +274,9 @@ module Pgoutput
274
274
  [input, nil]
275
275
  end
276
276
 
277
- def default_primary_key(_event, values)
277
+ def default_primary_key(event, values)
278
+ key_columns = replica_identity_columns_for(event)
279
+ return key_from_columns(event, values, key_columns) unless key_columns.nil?
278
280
  return nil unless values.respond_to?(:key?)
279
281
 
280
282
  if values.key?('id')
@@ -284,6 +286,27 @@ module Pgoutput
284
286
  end
285
287
  end
286
288
 
289
+ def replica_identity_columns_for(event)
290
+ return normalize_key_columns(event.replica_identity_columns) if event.respond_to?(:replica_identity_columns)
291
+ return normalize_key_columns(event.key_columns) if event.respond_to?(:key_columns)
292
+ return unless event.respond_to?(:relation) && event.relation.respond_to?(:columns)
293
+
294
+ normalize_key_columns(
295
+ event.relation.columns.select { |column| column.flags.allbits?(1) }.map(&:name)
296
+ )
297
+ end
298
+
299
+ def normalize_key_columns(columns)
300
+ column_list = Array(columns) # : Array[untyped]
301
+ column_list.map(&:to_s)
302
+ end
303
+
304
+ def key_from_columns(event, values, columns)
305
+ return nil if columns.empty?
306
+
307
+ ReplicaIdentityResolver.extract(event, values, columns)
308
+ end
309
+
287
310
  def metadata_for(event)
288
311
  metadata = {
289
312
  'source' => SOURCE_NAME,
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgoutput
4
+ module SourceAdapter
5
+ # Resolves schema-aware replica keys from decoded row values.
6
+ #
7
+ # Relations may be keyed by pgoutput relation id, `[schema, table]`, or a
8
+ # `"schema.table"` string. Each value is the ordered list of replica-key
9
+ # columns. Relation-id mappings take precedence because they identify the
10
+ # exact relation metadata seen by the replication stream.
11
+ #
12
+ # @example Resolve a composite key
13
+ # resolver = ReplicaIdentityResolver.new(
14
+ # ["public", "memberships"] => ["tenant_id", "member_uuid"]
15
+ # )
16
+ #
17
+ # @api public
18
+ class ReplicaIdentityResolver
19
+ # Normalized relation keys mapped to ordered replica-identity columns.
20
+ #
21
+ # @return [Hash]
22
+ attr_reader :relations
23
+
24
+ # Extract a complete replica key from an ordered column list.
25
+ #
26
+ # @param event [Object] decoded row event with schema/table metadata
27
+ # @param values [Hash, nil] decoded row values
28
+ # @param columns [Array<String>] ordered replica-key column names
29
+ # @return [Hash{String=>Object}] complete replica key
30
+ # @raise [Error] when values or any configured column are unavailable
31
+ def self.extract(event, values, columns)
32
+ unless values.respond_to?(:key?)
33
+ raise Error, "replica identity values are unavailable for #{qualified_name(event)}"
34
+ end
35
+
36
+ columns.to_h do |column|
37
+ [column, fetch_column(event, values, column)]
38
+ end
39
+ end
40
+
41
+ # @param relations [Hash{Integer, Array<String>, Array<String>=>Array<String>}]
42
+ # relation identity to ordered replica-key column names
43
+ # @return [void]
44
+ def initialize(relations)
45
+ @relations = relations.to_h.transform_values { |columns| normalize_columns(columns) }.freeze
46
+ freeze
47
+ end
48
+
49
+ # Extract the configured replica key for a decoded row event.
50
+ #
51
+ # @param event [Object] decoded row event with relation/schema/table metadata
52
+ # @param values [Hash, nil] decoded row values
53
+ # @return [Hash{String=>Object}, nil] complete replica key, or `nil` when
54
+ # the relation is not configured
55
+ # @raise [Error] when a configured key column is absent from row values
56
+ def call(event, values)
57
+ columns = columns_for(event)
58
+ return nil unless columns
59
+
60
+ self.class.extract(event, values, columns)
61
+ end
62
+
63
+ def self.fetch_column(event, values, column)
64
+ return values[column] if values.key?(column)
65
+
66
+ symbol = column.to_sym
67
+ return values[symbol] if values.key?(symbol)
68
+
69
+ raise Error, "replica identity column #{column.inspect} is missing from #{qualified_name(event)} values"
70
+ end
71
+ private_class_method :fetch_column
72
+
73
+ def self.qualified_name(event)
74
+ [event.schema, event.table].compact.join('.')
75
+ end
76
+ private_class_method :qualified_name
77
+
78
+ private
79
+
80
+ def normalize_columns(columns)
81
+ column_list = Array(columns) # : Array[untyped]
82
+ names = column_list.map(&:to_s)
83
+ raise ArgumentError, 'replica identity columns must not be empty' if names.empty?
84
+ raise ArgumentError, 'replica identity columns must be unique' unless names.uniq.length == names.length
85
+
86
+ names.freeze
87
+ end
88
+
89
+ def columns_for(event)
90
+ relation_id = event.relation_id if event.respond_to?(:relation_id)
91
+ return relations[relation_id] if relation_id && relations.key?(relation_id)
92
+
93
+ schema = event.schema
94
+ table = event.table
95
+ relations[[schema.to_s, table.to_s]] || relations["#{schema}.#{table}"]
96
+ end
97
+ end
98
+ end
99
+ end
@@ -5,6 +5,6 @@ module Pgoutput
5
5
  # Current pgoutput-source-adapter gem version.
6
6
  #
7
7
  # @return [String] semantic version published to RubyGems.
8
- VERSION = '0.2.0'
8
+ VERSION = '0.3.0'
9
9
  end
10
10
  end
@@ -24,4 +24,5 @@ module Pgoutput
24
24
  end
25
25
  end
26
26
 
27
+ require_relative 'source_adapter/replica_identity_resolver'
27
28
  require_relative 'source_adapter/cdc'
@@ -80,7 +80,13 @@ module Pgoutput
80
80
 
81
81
  def stream_input: (untyped input) -> [untyped, untyped]
82
82
 
83
- def default_primary_key: (untyped _event, untyped values) -> (nil | untyped)
83
+ def default_primary_key: (untyped event, untyped values) -> (nil | untyped)
84
+
85
+ def replica_identity_columns_for: (untyped event) -> untyped
86
+
87
+ def normalize_key_columns: (untyped columns) -> Array[String]
88
+
89
+ def key_from_columns: (untyped event, untyped values, Array[String] columns) -> untyped
84
90
 
85
91
  def metadata_for: (untyped event) -> untyped
86
92
 
@@ -0,0 +1,20 @@
1
+ module Pgoutput
2
+ module SourceAdapter
3
+ class ReplicaIdentityResolver
4
+ @relations: untyped
5
+
6
+ attr_reader relations: untyped
7
+
8
+ def self.extract: (untyped event, untyped values, Array[String] columns) -> Hash[String, untyped]
9
+ def initialize: (untyped relations) -> void
10
+ def call: (untyped event, untyped values) -> untyped
11
+
12
+ private
13
+
14
+ def self.fetch_column: (untyped event, untyped values, String column) -> untyped
15
+ def self.qualified_name: (untyped event) -> String
16
+ def normalize_columns: (untyped columns) -> Array[String]
17
+ def columns_for: (untyped event) -> untyped
18
+ end
19
+ end
20
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgoutput-source-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -64,11 +64,13 @@ files:
64
64
  - lib/pgoutput/source/adapter.rb
65
65
  - lib/pgoutput/source_adapter.rb
66
66
  - lib/pgoutput/source_adapter/cdc.rb
67
+ - lib/pgoutput/source_adapter/replica_identity_resolver.rb
67
68
  - lib/pgoutput/source_adapter/version.rb
68
69
  - mise.toml
69
70
  - sig/pgoutput/source/adapter.rbs
70
71
  - sig/pgoutput/source_adapter.rbs
71
72
  - sig/pgoutput/source_adapter/cdc.rbs
73
+ - sig/pgoutput/source_adapter/replica_identity_resolver.rbs
72
74
  - sig/pgoutput/source_adapter/version.rbs
73
75
  homepage: https://github.com/kanutocd/pgoutput-source-adapter
74
76
  licenses: