pgsync 4.2.0__tar.gz → 4.2.1__tar.gz

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.
Files changed (75) hide show
  1. {pgsync-4.2.0 → pgsync-4.2.1}/PKG-INFO +13 -13
  2. {pgsync-4.2.0 → pgsync-4.2.1}/README.md +3 -3
  3. {pgsync-4.2.0 → pgsync-4.2.1}/README.rst +1 -1
  4. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/__init__.py +1 -1
  5. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/helper.py +1 -1
  6. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/node.py +4 -2
  7. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/querybuilder.py +128 -66
  8. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/redisqueue.py +3 -2
  9. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/settings.py +5 -5
  10. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/sync.py +69 -27
  11. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/trigger.py +1 -1
  12. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/urls.py +8 -8
  13. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/PKG-INFO +13 -13
  14. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/requires.txt +11 -11
  15. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_redisqueue.py +5 -4
  16. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_sync_nested_children.py +102 -0
  17. {pgsync-4.2.0 → pgsync-4.2.1}/AUTHORS.rst +0 -0
  18. {pgsync-4.2.0 → pgsync-4.2.1}/CONTRIBUTING.rst +0 -0
  19. {pgsync-4.2.0 → pgsync-4.2.1}/HISTORY.rst +0 -0
  20. {pgsync-4.2.0 → pgsync-4.2.1}/LICENSE +0 -0
  21. {pgsync-4.2.0 → pgsync-4.2.1}/MANIFEST.in +0 -0
  22. {pgsync-4.2.0 → pgsync-4.2.1}/bin/bootstrap +0 -0
  23. {pgsync-4.2.0 → pgsync-4.2.1}/bin/parallel_sync +0 -0
  24. {pgsync-4.2.0 → pgsync-4.2.1}/bin/pgsync +0 -0
  25. {pgsync-4.2.0 → pgsync-4.2.1}/docs/Makefile +0 -0
  26. {pgsync-4.2.0 → pgsync-4.2.1}/docs/authors.rst +0 -0
  27. {pgsync-4.2.0 → pgsync-4.2.1}/docs/changelog.rst +0 -0
  28. {pgsync-4.2.0 → pgsync-4.2.1}/docs/conf.py +0 -0
  29. {pgsync-4.2.0 → pgsync-4.2.1}/docs/contributing.rst +0 -0
  30. {pgsync-4.2.0 → pgsync-4.2.1}/docs/history.rst +0 -0
  31. {pgsync-4.2.0 → pgsync-4.2.1}/docs/index.rst +0 -0
  32. {pgsync-4.2.0 → pgsync-4.2.1}/docs/installation.rst +0 -0
  33. {pgsync-4.2.0 → pgsync-4.2.1}/docs/logo.png +0 -0
  34. {pgsync-4.2.0 → pgsync-4.2.1}/docs/make.bat +0 -0
  35. {pgsync-4.2.0 → pgsync-4.2.1}/docs/readme.rst +0 -0
  36. {pgsync-4.2.0 → pgsync-4.2.1}/docs/usage.rst +0 -0
  37. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/base.py +0 -0
  38. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/constants.py +0 -0
  39. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/exc.py +0 -0
  40. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/plugin.py +0 -0
  41. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/search_client.py +0 -0
  42. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/singleton.py +0 -0
  43. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/transform.py +0 -0
  44. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/utils.py +0 -0
  45. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/view.py +0 -0
  46. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/SOURCES.txt +0 -0
  47. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/dependency_links.txt +0 -0
  48. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/not-zip-safe +0 -0
  49. {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/top_level.txt +0 -0
  50. {pgsync-4.2.0 → pgsync-4.2.1}/pyproject.toml +0 -0
  51. {pgsync-4.2.0 → pgsync-4.2.1}/setup.cfg +0 -0
  52. {pgsync-4.2.0 → pgsync-4.2.1}/setup.py +0 -0
  53. {pgsync-4.2.0 → pgsync-4.2.1}/tests/__init__.py +0 -0
  54. {pgsync-4.2.0 → pgsync-4.2.1}/tests/conftest.py +0 -0
  55. {pgsync-4.2.0 → pgsync-4.2.1}/tests/fixtures/schema.json +0 -0
  56. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_base.py +0 -0
  57. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_constants.py +0 -0
  58. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_env_vars.py +0 -0
  59. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_helper.py +0 -0
  60. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_log_handlers.py +0 -0
  61. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_node.py +0 -0
  62. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_query_builder.py +0 -0
  63. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_search_client.py +0 -0
  64. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_settings.py +0 -0
  65. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_sync.py +0 -0
  66. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_sync_root.py +0 -0
  67. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_sync_single_child_fk_on_child.py +0 -0
  68. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_sync_single_child_fk_on_parent.py +0 -0
  69. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_transform.py +0 -0
  70. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_trigger.py +0 -0
  71. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_unique_behaviour.py +0 -0
  72. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_urls.py +0 -0
  73. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_utils.py +0 -0
  74. {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_view.py +0 -0
  75. {pgsync-4.2.0 → pgsync-4.2.1}/tests/testing_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsync
3
- Version: 4.2.0
3
+ Version: 4.2.1
4
4
  Summary: Postgres to Elasticsearch/OpenSearch sync
5
5
  Home-page: https://github.com/toluaina/pgsync
6
6
  Author: Tolu Aina
@@ -32,33 +32,33 @@ License-File: LICENSE
32
32
  License-File: AUTHORS.rst
33
33
  Requires-Dist: async-timeout==5.0.1
34
34
  Requires-Dist: backports-datetime-fromisoformat==2.0.3
35
- Requires-Dist: boto3==1.40.1
36
- Requires-Dist: botocore==1.40.1
35
+ Requires-Dist: boto3==1.40.35
36
+ Requires-Dist: botocore==1.40.35
37
37
  Requires-Dist: certifi==2025.8.3
38
- Requires-Dist: charset-normalizer==3.4.2
38
+ Requires-Dist: charset-normalizer==3.4.3
39
39
  Requires-Dist: click==8.1.8
40
40
  Requires-Dist: elastic-transport==8.17.1
41
- Requires-Dist: elasticsearch==8.19.0
41
+ Requires-Dist: elasticsearch==8.19.1
42
42
  Requires-Dist: elasticsearch-dsl==8.15.4
43
43
  Requires-Dist: environs==14.3.0
44
44
  Requires-Dist: events==0.5
45
- Requires-Dist: greenlet==3.2.3
45
+ Requires-Dist: greenlet==3.2.4
46
46
  Requires-Dist: idna==3.10
47
47
  Requires-Dist: jmespath==1.0.1
48
- Requires-Dist: marshmallow==4.0.0
48
+ Requires-Dist: marshmallow==4.0.1
49
49
  Requires-Dist: opensearch-dsl==2.1.0
50
50
  Requires-Dist: opensearch-py==3.0.0
51
51
  Requires-Dist: psycopg2-binary==2.9.10
52
52
  Requires-Dist: python-dateutil==2.9.0.post0
53
53
  Requires-Dist: python-dotenv==1.1.1
54
- Requires-Dist: redis==6.2.0
55
- Requires-Dist: requests==2.32.4
54
+ Requires-Dist: redis==6.4.0
55
+ Requires-Dist: requests==2.32.5
56
56
  Requires-Dist: requests-aws4auth==1.3.1
57
- Requires-Dist: s3transfer==0.13.1
57
+ Requires-Dist: s3transfer==0.14.0
58
58
  Requires-Dist: six==1.17.0
59
- Requires-Dist: sqlalchemy==2.0.42
59
+ Requires-Dist: sqlalchemy==2.0.43
60
60
  Requires-Dist: sqlparse==0.5.3
61
- Requires-Dist: typing-extensions==4.14.1
61
+ Requires-Dist: typing-extensions==4.15.0
62
62
  Requires-Dist: urllib3==1.26.20
63
63
  Dynamic: author
64
64
  Dynamic: author-email
@@ -88,7 +88,7 @@ expose structured denormalized documents in [Elasticsearch](https://www.elastic.
88
88
 
89
89
  - [Python](https://www.python.org) 3.9+
90
90
  - [Postgres](https://www.postgresql.org) 9.6+
91
- - [Redis](https://redis.io) 3.1.0+
91
+ - [Redis](https://redis.io) 3.1.0+ or [Valkey](https://valkey.io) 7.2.0+
92
92
  - [Elasticsearch](https://www.elastic.co/products/elastic-stack) 6.3.1+ or [OpenSearch](https://opensearch.org/) 1.3.7+
93
93
  - [SQLAlchemy](https://www.sqlalchemy.org) 1.3.4+
94
94
 
@@ -66,7 +66,7 @@ the search capabilities of [Elasticsearch](https://www.elastic.co/products/elast
66
66
 
67
67
  #### How it works
68
68
 
69
- PGSync is written in Python (supporting version 3.9 onwards) and the stack is composed of: [Redis](https://redis.io), [Elasticsearch](https://www.elastic.co/products/elastic-stack)/[OpenSearch](https://opensearch.org/), [Postgres](https://www.postgresql.org), and [SQLAlchemy](https://www.sqlalchemy.org).
69
+ PGSync is written in Python (supporting version 3.9 onwards) and the stack is composed of: [Redis](https://redis.io)/[Valkey](https://valkey.io), [Elasticsearch](https://www.elastic.co/products/elastic-stack)/[OpenSearch](https://opensearch.org/), [Postgres](https://www.postgresql.org), and [SQLAlchemy](https://www.sqlalchemy.org).
70
70
 
71
71
  PGSync leverages the [logical decoding](https://www.postgresql.org/docs/current/logicaldecoding.html) feature of [Postgres](https://www.postgresql.org) (introduced in PostgreSQL 9.4) to capture a continuous stream of change events.
72
72
  This feature needs to be enabled in your [Postgres](https://www.postgresql.org) configuration file by setting in the postgresql.conf file:
@@ -136,7 +136,7 @@ To start all services with Docker, follow these steps:
136
136
 
137
137
  Environment variable placeholders - full list [here](https://pgsync.com/env-vars):
138
138
 
139
- - redis_host_address — Address of the Redis server (e.g., host.docker.internal for local Docker setup)
139
+ - redis_host_address — Address of the Redis/Valkey server (e.g., host.docker.internal for local Docker setup)
140
140
  - username — PostgreSQL username
141
141
  - password — PostgreSQL password
142
142
  - postgres_host — Host address for PostgreSQL instance (e.g., host.docker.internal)
@@ -192,7 +192,7 @@ Key features of PGSync are:
192
192
 
193
193
  - [Python](https://www.python.org) 3.9+
194
194
  - [Postgres](https://www.postgresql.org) 9.6+
195
- - [Redis](https://redis.io) 3.1.0+
195
+ - [Redis](https://redis.io) 3.1.0+ or [Valkey](https://valkey.io) 7.2.0+
196
196
  - [Elasticsearch](https://www.elastic.co/products/elastic-stack) 6.3.1+ or [OpenSearch](https://opensearch.org/) 1.3.7+
197
197
  - [SQLAlchemy](https://www.sqlalchemy.org) 1.3.4+
198
198
 
@@ -10,7 +10,7 @@ expose structured denormalized documents in [Elasticsearch](https://www.elastic.
10
10
 
11
11
  - [Python](https://www.python.org) 3.9+
12
12
  - [Postgres](https://www.postgresql.org) 9.6+
13
- - [Redis](https://redis.io) 3.1.0+
13
+ - [Redis](https://redis.io) 3.1.0+ or [Valkey](https://valkey.io) 7.2.0+
14
14
  - [Elasticsearch](https://www.elastic.co/products/elastic-stack) 6.3.1+ or [OpenSearch](https://opensearch.org/) 1.3.7+
15
15
  - [SQLAlchemy](https://www.sqlalchemy.org) 1.3.4+
16
16
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = "Tolu Aina"
4
4
  __email__ = "tolu@pgsync.com"
5
- __version__ = "4.2.0"
5
+ __version__ = "4.2.1"
@@ -29,7 +29,7 @@ def teardown(
29
29
  Args:
30
30
  drop_db (bool, optional): Whether to drop the database. Defaults to True.
31
31
  truncate_db (bool, optional): Whether to truncate the database. Defaults to True.
32
- delete_redis (bool, optional): Whether to delete Redis. Defaults to True.
32
+ delete_redis (bool, optional): Whether to delete Redis/Valkey. Defaults to True.
33
33
  drop_index (bool, optional): Whether to drop the index. Defaults to True.
34
34
  delete_checkpoint (bool, optional): Whether to delete the checkpoint. Defaults to True.
35
35
  config (Optional[str], optional): The configuration file path. Defaults to None.
@@ -133,6 +133,7 @@ class Node(object):
133
133
  relationship: t.Optional[dict] = None
134
134
  parent: t.Optional[Node] = None
135
135
  base_tables: t.Optional[list] = None
136
+ is_through: bool = False
136
137
 
137
138
  def __post_init__(self):
138
139
  self.model: sa.sql.Alias = self.models(self.table, self.schema)
@@ -328,8 +329,9 @@ class Tree(threading.local):
328
329
  self.root = node
329
330
 
330
331
  self.tables.add(node.table)
331
- for through in node.relationship.throughs:
332
- self.tables.add(through.table)
332
+ for through_node in node.relationship.throughs:
333
+ through_node.is_through = True
334
+ self.tables.add(through_node.table)
333
335
 
334
336
  for child in nodes.get("children", []):
335
337
  node.add_child(self.build(child))
@@ -108,66 +108,125 @@ class QueryBuilder(threading.local):
108
108
  return expression
109
109
 
110
110
  # this is for handling non-through tables
111
- def get_foreign_keys(self, node_a: Node, node_b: Node) -> dict:
112
- if (node_a, node_b) not in self._cache:
113
- foreign_keys: dict = {}
114
- # if either offers a foreign_key via relationship, use it!
115
- if (
116
- node_a.relationship.foreign_key.parent
117
- or node_b.relationship.foreign_key.parent
118
- ):
119
- if node_a.relationship.foreign_key.parent:
120
- foreign_keys[node_a.parent.name] = sorted(
121
- node_a.relationship.foreign_key.parent
122
- )
123
- foreign_keys[node_a.name] = sorted(
124
- node_a.relationship.foreign_key.child
125
- )
126
- if node_b.relationship.foreign_key.parent:
127
- foreign_keys[node_b.parent.name] = sorted(
128
- node_b.relationship.foreign_key.parent
129
- )
130
- foreign_keys[node_b.name] = sorted(
131
- node_b.relationship.foreign_key.child
132
- )
133
-
134
- else:
135
- fkeys: dict = defaultdict(list)
136
- if node_a.model.foreign_keys:
137
- for key in node_a.model.original.foreign_keys:
138
- if key._table_key() == str(node_b.model.original):
139
- fkeys[
140
- f"{key.parent.table.schema}."
141
- f"{key.parent.table.name}"
142
- ].append(str(key.parent.name))
143
- fkeys[
144
- f"{key.column.table.schema}."
145
- f"{key.column.table.name}"
146
- ].append(str(key.column.name))
147
- if not fkeys:
148
- if node_b.model.original.foreign_keys:
149
- for key in node_b.model.original.foreign_keys:
150
- if key._table_key() == str(node_a.model.original):
151
- fkeys[
152
- f"{key.parent.table.schema}."
153
- f"{key.parent.table.name}"
154
- ].append(str(key.parent.name))
155
- fkeys[
156
- f"{key.column.table.schema}."
157
- f"{key.column.table.name}"
158
- ].append(str(key.column.name))
159
- if not fkeys:
160
- raise ForeignKeyError(
161
- f"No foreign key relationship between "
162
- f"{node_a.model.original} and {node_b.model.original}"
163
- )
111
+ def get_foreign_keys(
112
+ self, node_a: Node, node_b: Node
113
+ ) -> t.Dict[str, t.List[str]]:
114
+ """
115
+ Return all FK columns between node_a and node_b, keyed by fully qualified table name.
116
+ Example:
117
+ {
118
+ "public.child": ["child_fk1", "child_fk2"],
119
+ "public.parent": ["id1", "id2"],
120
+ }
121
+ """
122
+ cache_key: t.Tuple[t.Any, t.Any] = (node_a, node_b)
123
+ if cache_key in self._cache:
124
+ return self._cache[cache_key]
125
+
126
+ fkeys: t.MutableMapping[str, t.List[str]] = defaultdict(list)
127
+
128
+ def add(table_key: t.Optional[str], col: t.Optional[str]) -> None:
129
+ if not table_key or not col:
130
+ return
131
+ if col not in fkeys[table_key]:
132
+ fkeys[table_key].append(col)
133
+
134
+ def qname(sa_table: t.Any) -> t.Optional[str]:
135
+ """schema-qualified table name from a SQLAlchemy Table (or None)."""
136
+ if sa_table is None:
137
+ return None
138
+ schema = getattr(sa_table, "schema", None)
139
+ name = getattr(sa_table, "name", None) or getattr(
140
+ sa_table, "key", None
141
+ )
142
+ if not name:
143
+ return None
144
+ return f"{schema}.{name}" if schema else str(name)
145
+
146
+ def node_table_key(
147
+ node: t.Any, *, prefer_parent: bool
148
+ ) -> t.Optional[str]:
149
+ """Best-effort table key from node or its parent; never raises."""
150
+ if prefer_parent:
151
+ parent = getattr(node, "parent", None)
152
+ parent_name = getattr(parent, "name", None)
153
+ if parent_name:
154
+ return str(parent_name)
155
+ node_name = getattr(node, "name", None)
156
+ if node_name:
157
+ return str(node_name)
158
+ # fallback to sqlalchemy table
159
+ return qname(
160
+ getattr(getattr(node, "model", None), "original", None)
161
+ )
164
162
 
165
- for table, columns in fkeys.items():
166
- foreign_keys[table] = columns
163
+ # merge relationship provided hints (if any); do NOT short-circuit
164
+ for node in (node_a, node_b):
165
+ rel_fk = getattr(
166
+ getattr(node, "relationship", None), "foreign_key", None
167
+ )
168
+ if not rel_fk:
169
+ continue
167
170
 
168
- self._cache[(node_a, node_b)] = foreign_keys
171
+ parent_tbl_key = node_table_key(node, prefer_parent=True)
172
+ child_tbl_key = node_table_key(node, prefer_parent=False)
173
+
174
+ def merge_side(
175
+ side_obj: t.Any, fallback_tbl_key: t.Optional[str]
176
+ ) -> None:
177
+ # accept dict[str, list[str]] (preferred), or an iterable[str], or a single str
178
+ if isinstance(side_obj, dict):
179
+ for tbl, cols in side_obj.items():
180
+ tkey = str(tbl)
181
+ for c in cols or []:
182
+ add(tkey, str(c))
183
+ elif isinstance(side_obj, (list, tuple, set)):
184
+ for c in side_obj:
185
+ add(fallback_tbl_key, str(c))
186
+ elif isinstance(side_obj, str):
187
+ add(fallback_tbl_key, side_obj)
188
+
189
+ # parent table cols
190
+ merge_side(getattr(rel_fk, "parent", None), parent_tbl_key)
191
+ # child table cols
192
+ merge_side(getattr(rel_fk, "child", None), child_tbl_key)
193
+
194
+ # SQLAlchemy introspection in both directions (A -> B and B -> A)
195
+ A = getattr(getattr(node_a, "model", None), "original", None)
196
+ B = getattr(getattr(node_b, "model", None), "original", None)
197
+
198
+ A_q = qname(A)
199
+ B_q = qname(B)
200
+
201
+ # helper to compare tables
202
+ def same_table(t1: t.Any, t2: t.Any) -> bool:
203
+ return qname(t1) is not None and qname(t1) == qname(t2)
204
+
205
+ if A is not None and B is not None:
206
+ for fk in getattr(A, "foreign_keys", []):
207
+ # does A have an FK pointing to B?
208
+ if same_table(getattr(fk, "column", None).table, B):
209
+ # child col in A
210
+ add(qname(fk.parent.table), str(fk.parent.name))
211
+ # parent col in B
212
+ add(qname(fk.column.table), str(fk.column.name))
213
+
214
+ for fk in getattr(B, "foreign_keys", []):
215
+ # does B have an FK pointing to A?
216
+ if same_table(getattr(fk, "column", None).table, A):
217
+ # child col in B
218
+ add(qname(fk.parent.table), str(fk.parent.name))
219
+ # parent col in A
220
+ add(qname(fk.column.table), str(fk.column.name))
221
+
222
+ if not fkeys:
223
+ raise ForeignKeyError(
224
+ f"No foreign key relationship between {A_q or node_a} and {B_q or node_b}"
225
+ )
169
226
 
170
- return self._cache[(node_a, node_b)]
227
+ result: t.Dict[str, t.List[str]] = dict(fkeys)
228
+ self._cache[cache_key] = result
229
+ return result
171
230
 
172
231
  def _get_foreign_keys(self, node_a: Node, node_b: Node) -> dict:
173
232
  """This is for handling through nodes."""
@@ -462,15 +521,18 @@ class QueryBuilder(threading.local):
462
521
 
463
522
  def _through(self, node: Node) -> None: # noqa: C901
464
523
  through: Node = node.relationship.throughs[0]
465
- foreign_keys: dict = self.get_foreign_keys(node, through)
524
+ # base: fks from through -> node
525
+ base = self.get_foreign_keys(through, node)
526
+ foreign_keys: t.Dict[str, t.List[str]] = {
527
+ k: list(v) for k, v in base.items()
528
+ }
466
529
 
467
- for key, values in self.get_foreign_keys(through, node.parent).items():
468
- if key in foreign_keys:
469
- for value in values:
470
- if value not in foreign_keys[key]:
471
- foreign_keys[key].append(value)
530
+ for table, cols in self.get_foreign_keys(through, node.parent).items():
531
+ if table not in foreign_keys:
472
532
  continue
473
- foreign_keys[key] = values
533
+ dst = foreign_keys[table]
534
+ # extend uniquely and preserve order
535
+ dst.extend([col for col in cols if col not in dst])
474
536
 
475
537
  foreign_key_columns: list = self._get_column_foreign_keys(
476
538
  node.model.columns,
@@ -612,7 +674,7 @@ class QueryBuilder(threading.local):
612
674
 
613
675
  parent_foreign_key_columns: list = self._get_column_foreign_keys(
614
676
  through.columns,
615
- foreign_keys,
677
+ base,
616
678
  schema=node.schema,
617
679
  )
618
680
  where: list = []
@@ -662,7 +724,7 @@ class QueryBuilder(threading.local):
662
724
  left_foreign_keys = foreign_keys[node.name]
663
725
  right_foreign_keys: list = self._get_column_foreign_keys(
664
726
  through.columns,
665
- foreign_keys,
727
+ base,
666
728
  table=through.table,
667
729
  schema=node.schema,
668
730
  )
@@ -18,10 +18,10 @@ logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
20
  class RedisQueue(object):
21
- """Simple Queue with Redis Backend."""
21
+ """Simple Queue with Redis/Valkey Backend."""
22
22
 
23
23
  def __init__(self, name: str, namespace: str = "queue", **kwargs):
24
- """Init Simple Queue with Redis Backend."""
24
+ """Init Simple Queue with Redis/Valkey Backend."""
25
25
  url: str = get_redis_url(**kwargs)
26
26
  self.key: str = f"{namespace}:{name}"
27
27
  self._meta_key: str = f"{self.key}:meta"
@@ -88,6 +88,7 @@ class RedisQueue(object):
88
88
  """Delete all items from the named queue."""
89
89
  logger.info(f"Deleting redis key: {self.key}")
90
90
  self.__db.delete(self.key)
91
+ logger.info(f"Deleted redis key: {self.key}")
91
92
 
92
93
  def set_meta(self, value: t.Any) -> None:
93
94
  """Store an arbitrary JSON-serialisable value in a dedicated key."""
@@ -194,20 +194,20 @@ if PG_URL_RO:
194
194
  PG_SSLMODE_RO = None
195
195
  PG_SSLROOTCERT_RO = None
196
196
 
197
- # Redis:
197
+ # Redis/Valkey
198
198
  REDIS_AUTH = env.str("REDIS_AUTH", default=None)
199
199
  REDIS_USER = env.str("REDIS_USER", default=None)
200
200
  REDIS_DB = env.int("REDIS_DB", default=0)
201
201
  REDIS_HOST = env.str("REDIS_HOST", default="localhost")
202
- # redis poll interval (in secs)
202
+ # redis/valkey poll interval (in secs)
203
203
  REDIS_POLL_INTERVAL = env.float("REDIS_POLL_INTERVAL", default=0.01)
204
204
  REDIS_PORT = env.int("REDIS_PORT", default=6379)
205
- # number of items to read from Redis at a time
205
+ # number of items to read from Redis/Valkey at a time
206
206
  REDIS_READ_CHUNK_SIZE = env.int("REDIS_READ_CHUNK_SIZE", default=1000)
207
207
  REDIS_SCHEME = env.str("REDIS_SCHEME", default="redis")
208
- # redis socket connection timeout
208
+ # redis/valkey socket connection timeout
209
209
  REDIS_SOCKET_TIMEOUT = env.int("REDIS_SOCKET_TIMEOUT", default=5)
210
- # number of items to write to Redis at a time
210
+ # number of items to write to Redis/Valkey at a time
211
211
  REDIS_WRITE_CHUNK_SIZE = env.int("REDIS_WRITE_CHUNK_SIZE", default=500)
212
212
  REDIS_URL = env.str("REDIS_URL", default=None)
213
213
  REDIS_RETRY_ON_TIMEOUT = env.bool(
@@ -510,7 +510,9 @@ class Sync(Base, metaclass=Singleton):
510
510
  ) -> list:
511
511
  fields: dict = defaultdict(list)
512
512
  primary_values: list = [
513
- payload.data[key] for key in node.model.primary_keys
513
+ payload.data[key]
514
+ for key in node.model.primary_keys
515
+ if key in payload.data
514
516
  ]
515
517
  primary_fields: dict = dict(
516
518
  zip(node.model.primary_keys, primary_values)
@@ -586,7 +588,60 @@ class Sync(Base, metaclass=Singleton):
586
588
  def _insert_op(
587
589
  self, node: Node, filters: dict, payloads: t.List[Payload]
588
590
  ) -> dict:
589
- if node.table in self.tree.tables:
591
+ if node.is_through:
592
+
593
+ # handle case where we insert into a through table
594
+ # set the parent as the new entity that has changed
595
+ foreign_keys = self.query_builder.get_foreign_keys(
596
+ node.parent,
597
+ node,
598
+ )
599
+
600
+ # if the node is directly related to parent and share a common field name
601
+ for payload in payloads:
602
+ columns = foreign_keys.get(node.name, [])
603
+ for column in columns:
604
+ if column in payload.data:
605
+ filters[node.parent.table].append(
606
+ {column: payload.data[column]}
607
+ )
608
+
609
+ # find all Elasticsearch/OpenSearch docs with fields
610
+ # that match the filters and add their root to the query filter
611
+ """
612
+ +------+
613
+ | Root |
614
+ |------|
615
+ | id |
616
+ | ... |
617
+ +------+
618
+ |
619
+ | 1..*
620
+ v
621
+ +---------+ +---------------+ +---------+
622
+ | NodeA | 1 * | ThroughTable | * 1 | NodeB |
623
+ |---------|--------|---------------|--------|---------|
624
+ | id | | nodeA_id | | id |
625
+ | ... | | nodeB_id | | ... |
626
+ +---------+ +---------------+ +---------+
627
+ NodeA represents the grandparent from through table
628
+ NodeB represents the parent from through table
629
+ ThroughTable represents the relationship between NodeA and NodeB
630
+
631
+ """
632
+ _filters: list = []
633
+ for payload in payloads:
634
+ _filters = self._root_primary_key_resolver(
635
+ node.parent, payload, _filters
636
+ )
637
+ if node.parent.parent:
638
+ _filters = self._root_primary_key_resolver(
639
+ node.parent.parent, payload, _filters
640
+ )
641
+ if _filters:
642
+ filters[self.tree.root.table].extend(_filters)
643
+
644
+ elif node.table in self.tree.tables:
590
645
  if node.is_root:
591
646
  for payload in payloads:
592
647
  primary_values = [
@@ -639,20 +694,6 @@ class Sync(Base, metaclass=Singleton):
639
694
  if _filters:
640
695
  filters[self.tree.root.table].extend(_filters)
641
696
 
642
- else:
643
- # handle case where we insert into a through table
644
- # set the parent as the new entity that has changed
645
- foreign_keys = self.query_builder.get_foreign_keys(
646
- node.parent,
647
- node,
648
- )
649
-
650
- for payload in payloads:
651
- for i, key in enumerate(foreign_keys[node.name]):
652
- filters[node.parent.table].append(
653
- {foreign_keys[node.parent.name][i]: payload.data[key]}
654
- )
655
-
656
697
  return filters
657
698
 
658
699
  def _update_op(
@@ -1118,9 +1159,9 @@ class Sync(Base, metaclass=Singleton):
1118
1159
  @property
1119
1160
  def txid_current(self) -> int:
1120
1161
  """
1121
- Get last committed transaction id from the database or redis.
1162
+ Get last committed transaction id from the database or redis/valkey.
1122
1163
  """
1123
- # If we are in read-only mode, we can only get the txid from Redis
1164
+ # If we are in read-only mode, we can only get the txid from Redis/Valkey
1124
1165
  if getattr(self._thread_local, "read_only", False):
1125
1166
  return self.redis.get_meta(default={}).get("txid_current", 0)
1126
1167
  # If we are not in read-only mode, we can get the txid from the database
@@ -1138,6 +1179,7 @@ class Sync(Base, metaclass=Singleton):
1138
1179
  )
1139
1180
  else:
1140
1181
  payloads = self.redis.pop()
1182
+
1141
1183
  if payloads:
1142
1184
  logger.debug(f"_poll_redis: {payloads}")
1143
1185
  with self.lock:
@@ -1151,7 +1193,7 @@ class Sync(Base, metaclass=Singleton):
1151
1193
  @threaded
1152
1194
  @exception
1153
1195
  def poll_redis(self) -> None:
1154
- """Consumer which polls Redis continuously."""
1196
+ """Consumer which polls Redis/Valkey continuously."""
1155
1197
  if settings.PG_HOST_RO or settings.PG_PORT_RO:
1156
1198
  logger.info("Setting read only consumer")
1157
1199
  self._thread_local.read_only = True
@@ -1172,7 +1214,7 @@ class Sync(Base, metaclass=Singleton):
1172
1214
 
1173
1215
  @exception
1174
1216
  async def async_poll_redis(self) -> None:
1175
- """Consumer which polls Redis continuously."""
1217
+ """Consumer which polls Redis/Valkey continuously."""
1176
1218
  while True:
1177
1219
  await self._async_poll_redis()
1178
1220
 
@@ -1283,11 +1325,11 @@ class Sync(Base, metaclass=Singleton):
1283
1325
 
1284
1326
  def _on_publish(self, payloads: t.List[Payload]) -> None:
1285
1327
  """
1286
- Redis publish event handler.
1328
+ Redis/Valkey publish event handler.
1287
1329
 
1288
1330
  This is triggered by poll_redis.
1289
- It is called when an event is received from Redis.
1290
- Deserialize the payload from Redis and sync to Elasticsearch/OpenSearch
1331
+ It is called when an event is received from Redis/Valkey.
1332
+ Deserialize the payload from Redis/Valkey and sync to Elasticsearch/OpenSearch
1291
1333
  """
1292
1334
  # this is used for the views.
1293
1335
  # we substitute the views for the base table here
@@ -1424,9 +1466,9 @@ class Sync(Base, metaclass=Singleton):
1424
1466
 
1425
1467
  NB: pulls as well as receives in order to avoid missing data.
1426
1468
 
1427
- 1. Buffer all ongoing changes from db to Redis.
1469
+ 1. Buffer all ongoing changes from db to Redis/Valkey
1428
1470
  2. Pull everything so far and also replay replication logs.
1429
- 3. Consume all changes from Redis.
1471
+ 3. Consume all changes from Redis/Valkey.
1430
1472
  """
1431
1473
  if settings.USE_ASYNC:
1432
1474
  self._conn = self.engine.connect().connection
@@ -1442,14 +1484,14 @@ class Sync(Base, metaclass=Singleton):
1442
1484
  ]
1443
1485
 
1444
1486
  else:
1445
- # sync up to and produce items in the Redis cache
1487
+ # sync up to and produce items in the Redis/Valkey cache
1446
1488
  if self.producer:
1447
1489
  self.poll_db()
1448
1490
  # sync up to current transaction_id
1449
1491
  self.pull()
1450
1492
 
1451
1493
  # start a background worker consumer thread to
1452
- # poll Redis and populate Elasticsearch/OpenSearch
1494
+ # poll Redis/Valkey and populate Elasticsearch/OpenSearch
1453
1495
  if self.consumer:
1454
1496
  for _ in range(self.num_workers):
1455
1497
  self.poll_redis()
@@ -1,4 +1,4 @@
1
- """PGSync Trigger template.
1
+ """PGSync trigger template.
2
2
 
3
3
  This module contains a template for creating a PostgreSQL trigger function that notifies updates asynchronously.
4
4
  The trigger function constructs a notification as a JSON object and sends it to a channel using PG_NOTIFY.
@@ -136,18 +136,18 @@ def get_redis_url(
136
136
  db: t.Optional[str] = None,
137
137
  ) -> str:
138
138
  """
139
- Return the URL to connect to Redis.
139
+ Return the URL to connect to Redis/Valkey.
140
140
 
141
141
  Args:
142
- scheme (Optional[str]): The scheme to use for the Redis connection. Defaults to None.
143
- host (Optional[str]): The Redis host to connect to. Defaults to None.
144
- username (Optional[str]): The Redis username to use for authentication. Defaults to None.
145
- password (Optional[str]): The Redis password to use for authentication. Defaults to None.
146
- port (Optional[int]): The Redis port to connect to. Defaults to None.
147
- db (Optional[str]): The Redis database to connect to. Defaults to None.
142
+ scheme (Optional[str]): The scheme to use for the Redis/Valkey connection. Defaults to None.
143
+ host (Optional[str]): The Redis/Valkey host to connect to. Defaults to None.
144
+ username (Optional[str]): The Redis/Valkey username to use for authentication. Defaults to None.
145
+ password (Optional[str]): The Redis/Valkey password to use for authentication. Defaults to None.
146
+ port (Optional[int]): The Redis/Valkey port to connect to. Defaults to None.
147
+ db (Optional[str]): The Redis/Valkey database to connect to. Defaults to None.
148
148
 
149
149
  Returns:
150
- str: The Redis connection URL.
150
+ str: The Redis/Valkey connection URL.
151
151
  """
152
152
  host = host or REDIS_HOST
153
153
  username = username or REDIS_USER
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsync
3
- Version: 4.2.0
3
+ Version: 4.2.1
4
4
  Summary: Postgres to Elasticsearch/OpenSearch sync
5
5
  Home-page: https://github.com/toluaina/pgsync
6
6
  Author: Tolu Aina
@@ -32,33 +32,33 @@ License-File: LICENSE
32
32
  License-File: AUTHORS.rst
33
33
  Requires-Dist: async-timeout==5.0.1
34
34
  Requires-Dist: backports-datetime-fromisoformat==2.0.3
35
- Requires-Dist: boto3==1.40.1
36
- Requires-Dist: botocore==1.40.1
35
+ Requires-Dist: boto3==1.40.35
36
+ Requires-Dist: botocore==1.40.35
37
37
  Requires-Dist: certifi==2025.8.3
38
- Requires-Dist: charset-normalizer==3.4.2
38
+ Requires-Dist: charset-normalizer==3.4.3
39
39
  Requires-Dist: click==8.1.8
40
40
  Requires-Dist: elastic-transport==8.17.1
41
- Requires-Dist: elasticsearch==8.19.0
41
+ Requires-Dist: elasticsearch==8.19.1
42
42
  Requires-Dist: elasticsearch-dsl==8.15.4
43
43
  Requires-Dist: environs==14.3.0
44
44
  Requires-Dist: events==0.5
45
- Requires-Dist: greenlet==3.2.3
45
+ Requires-Dist: greenlet==3.2.4
46
46
  Requires-Dist: idna==3.10
47
47
  Requires-Dist: jmespath==1.0.1
48
- Requires-Dist: marshmallow==4.0.0
48
+ Requires-Dist: marshmallow==4.0.1
49
49
  Requires-Dist: opensearch-dsl==2.1.0
50
50
  Requires-Dist: opensearch-py==3.0.0
51
51
  Requires-Dist: psycopg2-binary==2.9.10
52
52
  Requires-Dist: python-dateutil==2.9.0.post0
53
53
  Requires-Dist: python-dotenv==1.1.1
54
- Requires-Dist: redis==6.2.0
55
- Requires-Dist: requests==2.32.4
54
+ Requires-Dist: redis==6.4.0
55
+ Requires-Dist: requests==2.32.5
56
56
  Requires-Dist: requests-aws4auth==1.3.1
57
- Requires-Dist: s3transfer==0.13.1
57
+ Requires-Dist: s3transfer==0.14.0
58
58
  Requires-Dist: six==1.17.0
59
- Requires-Dist: sqlalchemy==2.0.42
59
+ Requires-Dist: sqlalchemy==2.0.43
60
60
  Requires-Dist: sqlparse==0.5.3
61
- Requires-Dist: typing-extensions==4.14.1
61
+ Requires-Dist: typing-extensions==4.15.0
62
62
  Requires-Dist: urllib3==1.26.20
63
63
  Dynamic: author
64
64
  Dynamic: author-email
@@ -88,7 +88,7 @@ expose structured denormalized documents in [Elasticsearch](https://www.elastic.
88
88
 
89
89
  - [Python](https://www.python.org) 3.9+
90
90
  - [Postgres](https://www.postgresql.org) 9.6+
91
- - [Redis](https://redis.io) 3.1.0+
91
+ - [Redis](https://redis.io) 3.1.0+ or [Valkey](https://valkey.io) 7.2.0+
92
92
  - [Elasticsearch](https://www.elastic.co/products/elastic-stack) 6.3.1+ or [OpenSearch](https://opensearch.org/) 1.3.7+
93
93
  - [SQLAlchemy](https://www.sqlalchemy.org) 1.3.4+
94
94
 
@@ -1,30 +1,30 @@
1
1
  async-timeout==5.0.1
2
2
  backports-datetime-fromisoformat==2.0.3
3
- boto3==1.40.1
4
- botocore==1.40.1
3
+ boto3==1.40.35
4
+ botocore==1.40.35
5
5
  certifi==2025.8.3
6
- charset-normalizer==3.4.2
6
+ charset-normalizer==3.4.3
7
7
  click==8.1.8
8
8
  elastic-transport==8.17.1
9
- elasticsearch==8.19.0
9
+ elasticsearch==8.19.1
10
10
  elasticsearch-dsl==8.15.4
11
11
  environs==14.3.0
12
12
  events==0.5
13
- greenlet==3.2.3
13
+ greenlet==3.2.4
14
14
  idna==3.10
15
15
  jmespath==1.0.1
16
- marshmallow==4.0.0
16
+ marshmallow==4.0.1
17
17
  opensearch-dsl==2.1.0
18
18
  opensearch-py==3.0.0
19
19
  psycopg2-binary==2.9.10
20
20
  python-dateutil==2.9.0.post0
21
21
  python-dotenv==1.1.1
22
- redis==6.2.0
23
- requests==2.32.4
22
+ redis==6.4.0
23
+ requests==2.32.5
24
24
  requests-aws4auth==1.3.1
25
- s3transfer==0.13.1
25
+ s3transfer==0.14.0
26
26
  six==1.17.0
27
- sqlalchemy==2.0.42
27
+ sqlalchemy==2.0.43
28
28
  sqlparse==0.5.3
29
- typing-extensions==4.14.1
29
+ typing-extensions==4.15.0
30
30
  urllib3==1.26.20
@@ -3,7 +3,7 @@
3
3
  import json
4
4
 
5
5
  import pytest
6
- from mock import patch
6
+ from mock import call, patch
7
7
  from redis.exceptions import ConnectionError
8
8
 
9
9
  from pgsync.redisqueue import RedisQueue
@@ -87,9 +87,10 @@ class TestRedisQueue(object):
87
87
  queue.push([4, 5, 6])
88
88
  assert queue.qsize == 6
89
89
  queue.delete()
90
- mock_logger.info.assert_called_once_with(
91
- "Deleting redis key: queue:something"
92
- )
90
+ assert mock_logger.info.call_args_list == [
91
+ call("Deleting redis key: queue:something"),
92
+ call("Deleted redis key: queue:something"),
93
+ ]
93
94
  assert queue.qsize == 0
94
95
 
95
96
  def test_pop_visible_in_snapshot(self):
@@ -1797,6 +1797,108 @@ class TestNestedChildren(object):
1797
1797
  assert_resync_empty(sync, nodes)
1798
1798
  sync.search_client.close()
1799
1799
 
1800
+ def test_insert_grand_child_through_child_op(
1801
+ self,
1802
+ data,
1803
+ nodes,
1804
+ book_cls,
1805
+ author_cls,
1806
+ city_cls,
1807
+ country_cls,
1808
+ continent_cls,
1809
+ book_author_cls,
1810
+ ):
1811
+ """
1812
+ Insert a new grand child (author) via the through child (book_author),
1813
+ with an entirely new city/country/continent (Sydney/Australia).
1814
+ """
1815
+ book_author = book_author_cls(
1816
+ id=8,
1817
+ book_isbn="def", # attach new author to existing book 'def'
1818
+ author=author_cls(
1819
+ id=6,
1820
+ name="Italo Calvino",
1821
+ birth_year=1923,
1822
+ city=city_cls(
1823
+ id=6,
1824
+ name="Sydney",
1825
+ country=country_cls(
1826
+ id=6,
1827
+ name="Australia",
1828
+ continent=continent_cls(
1829
+ id=7,
1830
+ name="Australia",
1831
+ ),
1832
+ ),
1833
+ ),
1834
+ ),
1835
+ )
1836
+
1837
+ doc = {
1838
+ "index": "testdb",
1839
+ "database": "testdb",
1840
+ "nodes": nodes,
1841
+ }
1842
+
1843
+ sync = Sync(doc)
1844
+ session = sync.session
1845
+ with subtransactions(session):
1846
+ session.add(book_author)
1847
+
1848
+ sync.search_client.bulk(
1849
+ sync.index, [sort_list(doc) for doc in sync.sync()]
1850
+ )
1851
+ sync.search_client.refresh("testdb")
1852
+
1853
+ docs = search(sync.search_client, "testdb")
1854
+ assert len(docs) == 3
1855
+ docs = sorted(docs, key=lambda k: k["isbn"])
1856
+
1857
+ expected_def_meta = {
1858
+ "book": {"isbn": ["def"]},
1859
+ "author": {"id": [1, 2, 6]}, # new author id=6
1860
+ "book_author": {"id": [2, 5, 8]}, # new through id=8
1861
+ "book_language": {"id": [2, 5, 8]},
1862
+ "book_subject": {"id": [2, 5, 7]},
1863
+ "city": {"id": [1, 2, 6]}, # Sydney id=6 added
1864
+ "continent": {"id": [1, 2, 7]}, # Australia id=7 added
1865
+ "country": {"id": [1, 2, 6]}, # Australia id=6 added
1866
+ "language": {"id": [1, 2, 3]},
1867
+ "publisher": {"id": [2]},
1868
+ "subject": {"id": [2, 4, 5]},
1869
+ }
1870
+
1871
+ expected_new_author = {
1872
+ "id": 6,
1873
+ "name": "Italo Calvino",
1874
+ "city_label": {
1875
+ "id": 6,
1876
+ "name": "Sydney",
1877
+ "country_label": {
1878
+ "id": 6,
1879
+ "name": "Australia",
1880
+ "continent_label": {"name": "Australia"},
1881
+ },
1882
+ },
1883
+ }
1884
+
1885
+ # pull out the 'def' book doc
1886
+ def_doc = next(d for d in docs if d["isbn"] == "def")
1887
+
1888
+ # meta checks
1889
+ for key, val in expected_def_meta.items():
1890
+ assert def_doc["_meta"][key] == val
1891
+
1892
+ # author list contains new author
1893
+ assert any(
1894
+ a["id"] == expected_new_author["id"] for a in def_doc["authors"]
1895
+ )
1896
+ new_author_doc = next(a for a in def_doc["authors"] if a["id"] == 6)
1897
+ assert new_author_doc == expected_new_author
1898
+
1899
+ assert_resync_empty(sync, nodes)
1900
+ sync.search_client.close()
1901
+
1800
1902
  def test_delete_through_child_op(self, sync, data, nodes, book_author_cls):
1801
1903
  # delete a new through child with op
1802
1904
  doc = {
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes