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.
- {pgsync-4.2.0 → pgsync-4.2.1}/PKG-INFO +13 -13
- {pgsync-4.2.0 → pgsync-4.2.1}/README.md +3 -3
- {pgsync-4.2.0 → pgsync-4.2.1}/README.rst +1 -1
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/__init__.py +1 -1
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/helper.py +1 -1
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/node.py +4 -2
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/querybuilder.py +128 -66
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/redisqueue.py +3 -2
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/settings.py +5 -5
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/sync.py +69 -27
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/trigger.py +1 -1
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/urls.py +8 -8
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/PKG-INFO +13 -13
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/requires.txt +11 -11
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_redisqueue.py +5 -4
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_sync_nested_children.py +102 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/AUTHORS.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/CONTRIBUTING.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/HISTORY.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/LICENSE +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/MANIFEST.in +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/bin/bootstrap +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/bin/parallel_sync +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/bin/pgsync +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/Makefile +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/authors.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/changelog.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/conf.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/contributing.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/history.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/index.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/installation.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/logo.png +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/make.bat +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/readme.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/docs/usage.rst +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/base.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/constants.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/exc.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/plugin.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/search_client.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/singleton.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/transform.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/utils.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync/view.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/SOURCES.txt +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/dependency_links.txt +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/not-zip-safe +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pgsync.egg-info/top_level.txt +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/pyproject.toml +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/setup.cfg +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/setup.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/__init__.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/conftest.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/fixtures/schema.json +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_base.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_constants.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_env_vars.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_helper.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_log_handlers.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_node.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_query_builder.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_search_client.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_settings.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_sync.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_sync_root.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_sync_single_child_fk_on_child.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_sync_single_child_fk_on_parent.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_transform.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_trigger.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_unique_behaviour.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_urls.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_utils.py +0 -0
- {pgsync-4.2.0 → pgsync-4.2.1}/tests/test_view.py +0 -0
- {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.
|
|
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.
|
|
36
|
-
Requires-Dist: botocore==1.40.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
55
|
-
Requires-Dist: requests==2.32.
|
|
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.
|
|
57
|
+
Requires-Dist: s3transfer==0.14.0
|
|
58
58
|
Requires-Dist: six==1.17.0
|
|
59
|
-
Requires-Dist: sqlalchemy==2.0.
|
|
59
|
+
Requires-Dist: sqlalchemy==2.0.43
|
|
60
60
|
Requires-Dist: sqlparse==0.5.3
|
|
61
|
-
Requires-Dist: typing-extensions==4.
|
|
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
|
|
|
@@ -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
|
|
332
|
-
|
|
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(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
468
|
-
if
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
36
|
-
Requires-Dist: botocore==1.40.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
55
|
-
Requires-Dist: requests==2.32.
|
|
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.
|
|
57
|
+
Requires-Dist: s3transfer==0.14.0
|
|
58
58
|
Requires-Dist: six==1.17.0
|
|
59
|
-
Requires-Dist: sqlalchemy==2.0.
|
|
59
|
+
Requires-Dist: sqlalchemy==2.0.43
|
|
60
60
|
Requires-Dist: sqlparse==0.5.3
|
|
61
|
-
Requires-Dist: typing-extensions==4.
|
|
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.
|
|
4
|
-
botocore==1.40.
|
|
3
|
+
boto3==1.40.35
|
|
4
|
+
botocore==1.40.35
|
|
5
5
|
certifi==2025.8.3
|
|
6
|
-
charset-normalizer==3.4.
|
|
6
|
+
charset-normalizer==3.4.3
|
|
7
7
|
click==8.1.8
|
|
8
8
|
elastic-transport==8.17.1
|
|
9
|
-
elasticsearch==8.19.
|
|
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.
|
|
13
|
+
greenlet==3.2.4
|
|
14
14
|
idna==3.10
|
|
15
15
|
jmespath==1.0.1
|
|
16
|
-
marshmallow==4.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.
|
|
23
|
-
requests==2.32.
|
|
22
|
+
redis==6.4.0
|
|
23
|
+
requests==2.32.5
|
|
24
24
|
requests-aws4auth==1.3.1
|
|
25
|
-
s3transfer==0.
|
|
25
|
+
s3transfer==0.14.0
|
|
26
26
|
six==1.17.0
|
|
27
|
-
sqlalchemy==2.0.
|
|
27
|
+
sqlalchemy==2.0.43
|
|
28
28
|
sqlparse==0.5.3
|
|
29
|
-
typing-extensions==4.
|
|
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.
|
|
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
|
|
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
|