feather-cdc-postgres 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,100 @@
1
+ <!--
2
+ VENDORED THIRD-PARTY CODE — read this before editing anything in this directory.
3
+
4
+ Origin: the `pg_replication` verified source of https://github.com/dlt-hub/verified-sources
5
+ Licence: Apache License 2.0, "Copyright 2023 The Contributors" — the full text is in
6
+ ./LICENSE, distributed with this package as section 4(a) requires.
7
+
8
+ MODIFIED. Section 4(b) of the licence asks that changed files carry a prominent notice,
9
+ so: `helpers.py` and `decoders.py` here are not upstream's. They carry the column
10
+ deny-list projection, the reviewed-layout quarantine, mid-transaction rename handling,
11
+ consistent baseline boundaries and generated-column publication that this runtime needs.
12
+
13
+ It is vendored rather than depended on because upstream is a `dlt init` TEMPLATE, not a
14
+ published distribution: there is no `pg_replication` on PyPI to pin. Upstream's own
15
+ requirements are incomplete (dlt-hub/verified-sources#685), which is why this package
16
+ declares `dlt[sql_database]` explicitly.
17
+
18
+ The README below is upstream's, kept for the protocol and set-up notes it carries.
19
+ -->
20
+
21
+ # Postgres replication
22
+ [Postgres](https://www.postgresql.org/) is one of the most popular relational database management systems. This verified source uses Postgres' replication functionality to efficiently process changes in tables (a process often referred to as _Change Data Capture_ or CDC). It uses [logical decoding](https://www.postgresql.org/docs/current/logicaldecoding.html) and the standard built-in `pgoutput` [output plugin](https://www.postgresql.org/docs/current/logicaldecoding-output-plugin.html).
23
+
24
+ Resources that can be loaded using this verified source are:
25
+
26
+ | Name | Description |
27
+ |----------------------|-------------------------------------------------|
28
+ | replication_resource | Load published messages from a replication slot |
29
+ | init_replication | Initialize replication and optionally return snapshot resources for initial data load |
30
+
31
+ ## Initialize the pipeline
32
+
33
+ ```bash
34
+ dlt init pg_replication duckdb
35
+ ```
36
+
37
+ This uses `duckdb` as destination, but you can choose any of the supported [destinations](https://dlthub.com/docs/dlt-ecosystem/destinations/).
38
+
39
+ ## Set up user
40
+
41
+ The Postgres user needs to have the `LOGIN` and `REPLICATION` attributes assigned:
42
+
43
+ ```sql
44
+ CREATE ROLE replication_user WITH LOGIN REPLICATION;
45
+ ```
46
+
47
+ It also needs `CREATE` privilege on the database:
48
+
49
+ ```sql
50
+ GRANT CREATE ON DATABASE dlt_data TO replication_user;
51
+ ```
52
+
53
+ If not a superuser, the user must have ownership of the tables that need to be replicated:
54
+
55
+ ```sql
56
+ ALTER TABLE your_table OWNER TO replication_user;
57
+ ```
58
+
59
+
60
+ ### Set up RDS
61
+ 1. You must enable replication for RDS Postgres instance via **Parameter Group**: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_PostgreSQL.Replication.ReadReplicas.html
62
+ 2. `WITH LOGIN REPLICATION;` does not work on RDS, instead do:
63
+ ```sql
64
+ GRANT rds_replication TO replication_user;
65
+ ```
66
+ 3. Do not fallback to non SSL connection by setting connection parameters:
67
+ ```toml
68
+ sources.pg_replication.credentials="postgresql://loader:password@host.rds.amazonaws.com:5432/dlt_data?sslmode=require&connect_timeout=300"
69
+ ```
70
+
71
+
72
+ ## Add credentials
73
+ 1. Open `.dlt/secrets.toml`.
74
+ 2. Enter your Postgres credentials:
75
+
76
+ ```toml
77
+ [sources.pg_replication]
78
+ credentials="postgresql://replication_user:<<password>>@localhost:5432/dlt_data"
79
+ ```
80
+ 3. Enter credentials for your chosen destination as per the [docs](https://dlthub.com/docs/dlt-ecosystem/destinations/).
81
+
82
+ ## Run the pipeline
83
+
84
+ 1. Install the necessary dependencies by running the following command:
85
+
86
+ ```bash
87
+ pip install -r requirements.txt
88
+ ```
89
+
90
+ 1. Now the pipeline can be run by using the command:
91
+
92
+ ```bash
93
+ python pg_replication_pipeline.py
94
+ ```
95
+
96
+ 1. To make sure that everything is loaded as expected, use the command:
97
+
98
+ ```bash
99
+ dlt pipeline pg_replication_pipeline show
100
+ ```
@@ -0,0 +1,106 @@
1
+ """Replicates postgres tables in batch using logical decoding."""
2
+
3
+ from typing import Dict, Sequence, Optional, Iterable, Union
4
+
5
+ import dlt
6
+
7
+ from dlt.common import logger
8
+ from dlt.common.typing import TDataItem
9
+ from dlt.common.schema.typing import TTableSchemaColumns
10
+ from dlt.extract.items import DataItemWithMeta
11
+ from dlt.sources.credentials import ConnectionStringCredentials
12
+
13
+ from .helpers import advance_slot, get_max_lsn, ItemGenerator
14
+
15
+
16
+ @dlt.resource(
17
+ name=lambda args: args["slot_name"],
18
+ standalone=True,
19
+ )
20
+ def replication_resource(
21
+ slot_name: str,
22
+ pub_name: str,
23
+ credentials: ConnectionStringCredentials = dlt.secrets.value,
24
+ include_columns: Optional[Dict[str, Sequence[str]]] = None,
25
+ columns: Optional[Dict[str, TTableSchemaColumns]] = None,
26
+ target_batch_size: int = 1000,
27
+ flush_slot: bool = True,
28
+ ) -> Iterable[Union[TDataItem, DataItemWithMeta]]:
29
+ """Resource yielding data items for changes in one or more postgres tables.
30
+
31
+ - Relies on a replication slot and publication that publishes DML operations
32
+ (i.e. `insert`, `update`, and/or `delete`). Helper `init_replication` can be
33
+ used to set this up.
34
+ - Maintains LSN of last consumed message in state to track progress.
35
+ - At start of the run, advances the slot upto last consumed message in previous run.
36
+ - Processes in batches to limit memory usage.
37
+
38
+ Args:
39
+ slot_name (str): Name of the replication slot to consume replication messages from.
40
+ pub_name (str): Name of the publication that publishes DML operations for the table(s).
41
+ credentials (ConnectionStringCredentials): Postgres database credentials.
42
+ include_columns (Optional[Dict[str, Sequence[str]]]): Maps table name(s) to
43
+ sequence of names of columns to include in the generated data items.
44
+ Any column not in the sequence is excluded. If not provided, all columns
45
+ are included. For example:
46
+ ```
47
+ include_columns={
48
+ "table_x": ["col_a", "col_c"],
49
+ "table_y": ["col_x", "col_y", "col_z"],
50
+ }
51
+ ```
52
+ columns (Optional[Dict[str, TTableHintTemplate[TAnySchemaColumns]]]): Maps
53
+ table name(s) to column hints to apply on the replicated table(s). For example:
54
+ ```
55
+ columns={
56
+ "table_x": {"col_a": {"data_type": "json"}},
57
+ "table_y": {"col_y": {"precision": 32}},
58
+ }
59
+ ```
60
+ target_batch_size (int): Desired number of data items yielded in a batch.
61
+ Can be used to limit the data items in memory. Note that the number of
62
+ data items yielded can be (far) greater than `target_batch_size`, because
63
+ all messages belonging to the same transaction are always processed in
64
+ the same batch, regardless of the number of messages in the transaction
65
+ and regardless of the value of `target_batch_size`. The number of data
66
+ items can also be smaller than `target_batch_size` when the replication
67
+ slot is exhausted before a batch is full.
68
+ flush_slot (bool): Whether processed messages are discarded from the replication
69
+ slot. Recommended value is True. Be careful when setting False—not flushing
70
+ can eventually lead to a “disk full” condition on the server, because
71
+ the server retains all the WAL segments that might be needed to stream
72
+ the changes via all of the currently open replication slots.
73
+
74
+ Yields:
75
+ Data items for changes published in the publication.
76
+ """
77
+ # start where we left off in previous run
78
+ start_lsn = dlt.current.resource_state().get("last_commit_lsn", 0)
79
+ if flush_slot and start_lsn:
80
+ advance_slot(start_lsn, slot_name, credentials)
81
+
82
+ # continue until last message in replication slot
83
+ options = {"publication_names": pub_name, "proto_version": "1"}
84
+ upto_lsn = get_max_lsn(slot_name, options, credentials)
85
+ if upto_lsn is None:
86
+ return
87
+ logger.info(
88
+ f"Replicating slot {slot_name} publication {pub_name} from {start_lsn} to {upto_lsn}"
89
+ )
90
+ # generate items in batches
91
+ while True:
92
+ gen = ItemGenerator(
93
+ credentials=credentials,
94
+ slot_name=slot_name,
95
+ options=options,
96
+ upto_lsn=upto_lsn,
97
+ start_lsn=start_lsn,
98
+ target_batch_size=target_batch_size,
99
+ include_columns=include_columns,
100
+ columns=columns,
101
+ )
102
+ yield from gen
103
+ if gen.generated_all:
104
+ dlt.current.resource_state()["last_commit_lsn"] = gen.last_commit_lsn
105
+ break
106
+ start_lsn = gen.last_commit_lsn
@@ -0,0 +1,428 @@
1
+ # flake8: noqa
2
+ # file copied from https://raw.githubusercontent.com/dgea005/pypgoutput/master/src/pypgoutput/decoders.py
3
+ # we do this instead of importing `pypgoutput` because it depends on `psycopg2`, which causes errors when installing on macOS
4
+
5
+ import io
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timedelta, timezone
9
+ from typing import List, NamedTuple, Optional, Union
10
+
11
+ # integer byte lengths
12
+ INT8 = 1
13
+ INT16 = 2
14
+ INT32 = 4
15
+ INT64 = 8
16
+
17
+
18
+ def convert_pg_ts(_ts_in_microseconds: int) -> datetime:
19
+ ts = datetime(2000, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc)
20
+ return ts + timedelta(microseconds=_ts_in_microseconds)
21
+
22
+
23
+ def convert_bytes_to_int(_in_bytes: bytes) -> int:
24
+ return int.from_bytes(_in_bytes, byteorder="big", signed=True)
25
+
26
+
27
+ def convert_bytes_to_utf8(_in_bytes: Union[bytes, bytearray]) -> str:
28
+ return (_in_bytes).decode("utf-8")
29
+
30
+
31
+ class ColumnData(NamedTuple):
32
+ # col_data_category is NOT the type. it means null value/toasted(not sent)/text formatted
33
+ col_data_category: Optional[str]
34
+ col_data_length: Optional[int] = None
35
+ col_data: Optional[str] = None
36
+
37
+ def __repr__(self) -> str:
38
+ return f"[col_data_category='{self.col_data_category}', col_data_length={self.col_data_length}, col_data='{self.col_data}']"
39
+
40
+
41
+ class ColumnType(NamedTuple):
42
+ """https://www.postgresql.org/docs/12/catalog-pg-attribute.html"""
43
+
44
+ part_of_pkey: int
45
+ name: str
46
+ type_id: int
47
+ atttypmod: int
48
+
49
+
50
+ class TupleData(NamedTuple):
51
+ n_columns: int
52
+ column_data: List[ColumnData]
53
+
54
+ def __repr__(self) -> str:
55
+ return f"n_columns: {self.n_columns}, data: {self.column_data}"
56
+
57
+
58
+ # TODO: you can make decoding way faster by
59
+ # - moving all the decoding core to PgoutputMessage
60
+ # - use struct unpack and increase offset manually to reduce calls
61
+ # - use tuples to represent data, separate data from decoding!
62
+ class PgoutputMessage(ABC):
63
+ def __init__(self, buffer: bytes):
64
+ self.buffer: io.BytesIO = io.BytesIO(buffer)
65
+ self.byte1: str = self.read_utf8(1)
66
+ self.decode_buffer()
67
+
68
+ @abstractmethod
69
+ def decode_buffer(self) -> None:
70
+ """Decoding is implemented for each message type"""
71
+
72
+ @abstractmethod
73
+ def __repr__(self) -> str:
74
+ """Implemented for each message type"""
75
+
76
+ def read_int8(self) -> int:
77
+ return convert_bytes_to_int(self.buffer.read(INT8))
78
+
79
+ def read_int16(self) -> int:
80
+ return convert_bytes_to_int(self.buffer.read(INT16))
81
+
82
+ def read_int32(self) -> int:
83
+ return convert_bytes_to_int(self.buffer.read(INT32))
84
+
85
+ def read_int64(self) -> int:
86
+ return convert_bytes_to_int(self.buffer.read(INT64))
87
+
88
+ def read_utf8(self, n: int = 1) -> str:
89
+ return convert_bytes_to_utf8(self.buffer.read(n))
90
+
91
+ def read_timestamp(self) -> datetime:
92
+ # 8 chars -> int64 -> timestamp
93
+ return convert_pg_ts(_ts_in_microseconds=self.read_int64())
94
+
95
+ def read_string(self) -> str:
96
+ output = bytearray()
97
+ while (next_char := self.buffer.read(1)) != b"\x00":
98
+ output += next_char
99
+ return convert_bytes_to_utf8(output)
100
+
101
+ def read_tuple_data(self) -> TupleData:
102
+ """
103
+ TupleData
104
+ Int16 Number of columns.
105
+ Next, one of the following submessages appears for each column (except generated columns):
106
+ Byte1('n') Identifies the data as NULL value.
107
+ Or
108
+ Byte1('u') Identifies unchanged TOASTed value (the actual value is not sent).
109
+ Or
110
+ Byte1('t') Identifies the data as text formatted value.
111
+ Int32 Length of the column value.
112
+ Byten The value of the column, in text format. (A future release might support additional formats.) n is the above length.
113
+ """
114
+ # TODO: investigate what happens with the generated columns
115
+ column_data = list()
116
+ n_columns = self.read_int16()
117
+ for column in range(n_columns):
118
+ col_data_category = self.read_utf8()
119
+ if col_data_category in ("n", "u"):
120
+ # "n"=NULL, "t"=TOASTed
121
+ column_data.append(ColumnData(col_data_category=col_data_category))
122
+ elif col_data_category == "t":
123
+ # t = tuple
124
+ col_data_length = self.read_int32()
125
+ col_data = self.read_utf8(col_data_length)
126
+ column_data.append(
127
+ ColumnData(
128
+ col_data_category=col_data_category,
129
+ col_data_length=col_data_length,
130
+ col_data=col_data,
131
+ )
132
+ )
133
+ return TupleData(n_columns=n_columns, column_data=column_data)
134
+
135
+
136
+ class Begin(PgoutputMessage):
137
+ """
138
+ https://pgpedia.info/x/xlogrecptr.html
139
+ https://www.postgresql.org/docs/14/datatype-pg-lsn.html
140
+
141
+ byte1 Byte1('B') Identifies the message as a begin message.
142
+ lsn Int64 The final LSN of the transaction.
143
+ commit_tx_ts Int64 Commit timestamp of the transaction. The value is in number of microseconds since PostgreSQL epoch (2000-01-01).
144
+ tx_xid Int32 Xid of the transaction.
145
+ """
146
+
147
+ byte1: str
148
+ lsn: int
149
+ commit_ts: datetime
150
+ tx_xid: int
151
+
152
+ def decode_buffer(self) -> None:
153
+ if self.byte1 != "B":
154
+ raise ValueError("first byte in buffer does not match Begin message")
155
+ self.lsn = self.read_int64()
156
+ self.commit_ts = self.read_timestamp()
157
+ self.tx_xid = self.read_int64()
158
+
159
+ def __repr__(self) -> str:
160
+ return (
161
+ f"BEGIN \n\tbyte1: '{self.byte1}', \n\tLSN: {self.lsn}, "
162
+ f"\n\tcommit_ts {self.commit_ts}, \n\ttx_xid: {self.tx_xid}"
163
+ )
164
+
165
+
166
+ class Commit(PgoutputMessage):
167
+ """
168
+ byte1: Byte1('C') Identifies the message as a commit message.
169
+ flags: Int8 Flags; currently unused (must be 0).
170
+ lsn_commit: Int64 The LSN of the commit.
171
+ lsn: Int64 The end LSN of the transaction.
172
+ Int64 Commit timestamp of the transaction. The value is in number of microseconds since PostgreSQL epoch (2000-01-01).
173
+ """
174
+
175
+ byte1: str
176
+ flags: int
177
+ lsn_commit: int
178
+ lsn: int
179
+ commit_ts: datetime
180
+
181
+ def decode_buffer(self) -> None:
182
+ if self.byte1 != "C":
183
+ raise ValueError("first byte in buffer does not match Commit message")
184
+ self.flags = self.read_int8()
185
+ self.lsn_commit = self.read_int64()
186
+ self.lsn = self.read_int64()
187
+ self.commit_ts = self.read_timestamp()
188
+
189
+ def __repr__(self) -> str:
190
+ return (
191
+ f"COMMIT \n\tbyte1: {self.byte1}, \n\tflags {self.flags}, \n\tlsn_commit: {self.lsn_commit}"
192
+ f"\n\tLSN: {self.lsn}, \n\tcommit_ts {self.commit_ts}"
193
+ )
194
+
195
+
196
+ class Origin:
197
+ """
198
+ Byte1('O') Identifies the message as an origin message.
199
+ Int64 The LSN of the commit on the origin server.
200
+ String Name of the origin.
201
+ Note that there can be multiple Origin messages inside a single transaction.
202
+ This seems to be what origin means: https://www.postgresql.org/docs/12/replication-origins.html
203
+ """
204
+
205
+ pass
206
+
207
+
208
+ class Relation(PgoutputMessage):
209
+ """
210
+ Byte1('R') Identifies the message as a relation message.
211
+ Int32 ID of the relation.
212
+ String Namespace (empty string for pg_catalog).
213
+ String Relation name.
214
+ Int8 Replica identity setting for the relation (same as relreplident in pg_class).
215
+ # select relreplident from pg_class where relname = 'test_table';
216
+ # from reading the documentation and looking at the tables this is not int8 but a single character
217
+ # background: https://www.postgresql.org/docs/10/sql-altertable.html#SQL-CREATETABLE-REPLICA-IDENTITY
218
+ Int16 Number of columns.
219
+ Next, the following message part appears for each column (except generated columns):
220
+ Int8 Flags for the column. Currently can be either 0 for no flags or 1 which marks the column as part of the key.
221
+ String Name of the column.
222
+ Int32 ID of the column's data type.
223
+ Int32 Type modifier of the column (atttypmod).
224
+ """
225
+
226
+ byte1: str
227
+ relation_id: int
228
+ namespace: str
229
+ relation_name: str
230
+ replica_identity_setting: str
231
+ n_columns: int
232
+ columns: List[ColumnType]
233
+
234
+ def decode_buffer(self) -> None:
235
+ if self.byte1 != "R":
236
+ raise ValueError("first byte in buffer does not match Relation message")
237
+ self.relation_id = self.read_int32()
238
+ self.namespace = self.read_string()
239
+ self.relation_name = self.read_string()
240
+ self.replica_identity_setting = self.read_utf8()
241
+ self.n_columns = self.read_int16()
242
+ self.columns = list()
243
+
244
+ for column in range(self.n_columns):
245
+ part_of_pkey = self.read_int8()
246
+ col_name = self.read_string()
247
+ data_type_id = self.read_int32()
248
+ # TODO: check on use of signed / unsigned
249
+ # check with select oid from pg_type where typname = <type>; timestamp == 1184, int4 = 23
250
+ col_modifier = self.read_int32()
251
+ self.columns.append(
252
+ ColumnType(
253
+ part_of_pkey=part_of_pkey,
254
+ name=col_name,
255
+ type_id=data_type_id,
256
+ atttypmod=col_modifier,
257
+ )
258
+ )
259
+
260
+ def __repr__(self) -> str:
261
+ return (
262
+ f"RELATION \n\tbyte1: '{self.byte1}', \n\trelation_id: {self.relation_id}"
263
+ f",\n\tnamespace/schema: '{self.namespace}',\n\trelation_name: '{self.relation_name}'"
264
+ f",\n\treplica_identity_setting: '{self.replica_identity_setting}',\n\tn_columns: {self.n_columns} "
265
+ f",\n\tcolumns: {self.columns}"
266
+ )
267
+
268
+
269
+ class PgType:
270
+ """
271
+ Renamed to PgType not to collide with "type"
272
+
273
+ Byte1('Y') Identifies the message as a type message.
274
+ Int32 ID of the data type.
275
+ String Namespace (empty string for pg_catalog).
276
+ String Name of the data type.
277
+ """
278
+
279
+ pass
280
+
281
+
282
+ class Insert(PgoutputMessage):
283
+ """
284
+ Byte1('I') Identifies the message as an insert message.
285
+ Int32 ID of the relation corresponding to the ID in the relation message.
286
+ Byte1('N') Identifies the following TupleData message as a new tuple.
287
+ TupleData TupleData message part representing the contents of new tuple.
288
+ """
289
+
290
+ byte1: str
291
+ relation_id: int
292
+ new_tuple_byte: str
293
+ new_tuple: TupleData
294
+
295
+ def decode_buffer(self) -> None:
296
+ if self.byte1 != "I":
297
+ raise ValueError(
298
+ f"first byte in buffer does not match Insert message (expected 'I', got '{self.byte1}'"
299
+ )
300
+ self.relation_id = self.read_int32()
301
+ self.new_tuple_byte = self.read_utf8()
302
+ self.new_tuple = self.read_tuple_data()
303
+
304
+ def __repr__(self) -> str:
305
+ return (
306
+ f"INSERT \n\tbyte1: '{self.byte1}', \n\trelation_id: {self.relation_id} "
307
+ f"\n\tnew tuple byte: '{self.new_tuple_byte}', \n\tnew_tuple: {self.new_tuple}"
308
+ )
309
+
310
+
311
+ class Update(PgoutputMessage):
312
+ """
313
+ Byte1('U') Identifies the message as an update message.
314
+ Int32 ID of the relation corresponding to the ID in the relation message.
315
+ Byte1('K') Identifies the following TupleData submessage as a key. This field is optional and is only present if the update changed data in any of the column(s) that are part of the REPLICA IDENTITY index.
316
+ Byte1('O') Identifies the following TupleData submessage as an old tuple. This field is optional and is only present if table in which the update happened has REPLICA IDENTITY set to FULL.
317
+ TupleData TupleData message part representing the contents of the old tuple or primary key. Only present if the previous 'O' or 'K' part is present.
318
+ Byte1('N') Identifies the following TupleData message as a new tuple.
319
+ TupleData TupleData message part representing the contents of a new tuple.
320
+
321
+ The Update message may contain either a 'K' message part or an 'O' message part or neither of them, but never both of them.
322
+ """
323
+
324
+ byte1: str
325
+ relation_id: int
326
+ next_byte_identifier: Optional[str]
327
+ optional_tuple_identifier: Optional[str]
328
+ old_tuple: Optional[TupleData]
329
+ new_tuple_byte: str
330
+ new_tuple: TupleData
331
+
332
+ def decode_buffer(self) -> None:
333
+ self.optional_tuple_identifier = None
334
+ self.old_tuple = None
335
+ if self.byte1 != "U":
336
+ raise ValueError(
337
+ f"first byte in buffer does not match Update message (expected 'U', got '{self.byte1}'"
338
+ )
339
+ self.relation_id = self.read_int32()
340
+ # TODO test update to PK, test update with REPLICA IDENTITY = FULL
341
+ self.next_byte_identifier = self.read_utf8() # one of K, O or N
342
+ if self.next_byte_identifier == "K" or self.next_byte_identifier == "O":
343
+ self.optional_tuple_identifier = self.next_byte_identifier
344
+ self.old_tuple = self.read_tuple_data()
345
+ self.new_tuple_byte = self.read_utf8()
346
+ else:
347
+ self.new_tuple_byte = self.next_byte_identifier
348
+ if self.new_tuple_byte != "N":
349
+ # TODO: test exception handling
350
+ raise ValueError(
351
+ f"did not find new_tuple_byte ('N') at position: {self.buffer.tell()}, found: '{self.new_tuple_byte}'"
352
+ )
353
+ self.new_tuple = self.read_tuple_data()
354
+
355
+ def __repr__(self) -> str:
356
+ return (
357
+ f"UPDATE \n\tbyte1: '{self.byte1}', \n\trelation_id: {self.relation_id}"
358
+ f"\n\toptional_tuple_identifier: '{self.optional_tuple_identifier}', \n\toptional_old_tuple_data: {self.old_tuple}"
359
+ f"\n\tnew_tuple_byte: '{self.new_tuple_byte}', \n\tnew_tuple: {self.new_tuple}"
360
+ )
361
+
362
+
363
+ class Delete(PgoutputMessage):
364
+ """
365
+ Byte1('D') Identifies the message as a delete message.
366
+ Int32 ID of the relation corresponding to the ID in the relation message.
367
+ Byte1('K') Identifies the following TupleData submessage as a key. This field is present if the table in which the delete has happened uses an index as REPLICA IDENTITY.
368
+ Byte1('O') Identifies the following TupleData message as a old tuple. This field is present if the table in which the delete has happened has REPLICA IDENTITY set to FULL.
369
+ TupleData TupleData message part representing the contents of the old tuple or primary key, depending on the previous field.
370
+
371
+ The Delete message may contain either a 'K' message part or an 'O' message part, but never both of them.
372
+ """
373
+
374
+ byte1: str
375
+ relation_id: int
376
+ message_type: str
377
+ old_tuple: TupleData
378
+
379
+ def decode_buffer(self) -> None:
380
+ if self.byte1 != "D":
381
+ raise ValueError(
382
+ f"first byte in buffer does not match Delete message (expected 'D', got '{self.byte1}'"
383
+ )
384
+ self.relation_id = self.read_int32()
385
+ self.message_type = self.read_utf8()
386
+ # TODO: test with replica identity full
387
+ if self.message_type not in ["K", "O"]:
388
+ raise ValueError(
389
+ f"message type byte is not 'K' or 'O', got: '{self.message_type}'"
390
+ )
391
+ self.old_tuple = self.read_tuple_data()
392
+
393
+ def __repr__(self) -> str:
394
+ return (
395
+ f"DELETE \n\tbyte1: {self.byte1} \n\trelation_id: {self.relation_id} "
396
+ f"\n\tmessage_type: {self.message_type} \n\told_tuple: {self.old_tuple}"
397
+ )
398
+
399
+
400
+ class Truncate(PgoutputMessage):
401
+ """
402
+ Byte1('T') Identifies the message as a truncate message.
403
+ Int32 Number of relations
404
+ Int8 Option bits for TRUNCATE: 1 for CASCADE, 2 for RESTART IDENTITY
405
+ Int32 ID of the relation corresponding to the ID in the relation message. This field is repeated for each relation.
406
+ """
407
+
408
+ byte1: str
409
+ number_of_relations: int
410
+ option_bits: int
411
+ relation_ids: List[int]
412
+
413
+ def decode_buffer(self) -> None:
414
+ if self.byte1 != "T":
415
+ raise ValueError(
416
+ f"first byte in buffer does not match Truncate message (expected 'T', got '{self.byte1}'"
417
+ )
418
+ self.number_of_relations = self.read_int32()
419
+ self.option_bits = self.read_int8()
420
+ self.relation_ids = []
421
+ for relation in range(self.number_of_relations):
422
+ self.relation_ids.append(self.read_int32())
423
+
424
+ def __repr__(self) -> str:
425
+ return (
426
+ f"TRUNCATE \n\tbyte1: {self.byte1} \n\tn_relations: {self.number_of_relations} "
427
+ f"option_bits: {self.option_bits}, relation_ids: {self.relation_ids}"
428
+ )
@@ -0,0 +1,6 @@
1
+ class NoPrimaryKeyException(Exception):
2
+ pass
3
+
4
+
5
+ class IncompatiblePostgresVersionException(Exception):
6
+ pass