pgsync 6.1.1__tar.gz → 7.0.0__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-6.1.1 → pgsync-7.0.0}/PKG-INFO +4 -4
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/__init__.py +1 -1
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/base.py +14 -4
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/redisqueue.py +23 -8
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/sync.py +28 -9
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/trigger.py +1 -1
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/PKG-INFO +4 -4
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/requires.txt +3 -3
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_redisqueue.py +1 -1
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_sync.py +372 -4
- {pgsync-6.1.1 → pgsync-7.0.0}/AUTHORS.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/CONTRIBUTING.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/HISTORY.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/LICENSE +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/MANIFEST.in +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/README.md +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/README.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/bin/bootstrap +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/bin/parallel_sync +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/bin/pgsync +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/Makefile +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/authors.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/changelog.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/conf.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/contributing.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/history.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/index.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/installation.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/logo.png +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/make.bat +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/readme.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/docs/usage.rst +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/constants.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/exc.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/helper.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/node.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/plugin.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/querybuilder.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/search_client.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/settings.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/singleton.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/transform.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/urls.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/utils.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/view.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/SOURCES.txt +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/dependency_links.txt +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/not-zip-safe +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/top_level.txt +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/pyproject.toml +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/setup.cfg +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/setup.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/__init__.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/conftest.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/fixtures/schema.json +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_base.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_constants.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_env_vars.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_helper.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_log_handlers.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_node.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_query_builder.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_search_client.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_settings.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_sync_nested_children.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_sync_root.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_sync_single_child_fk_on_child.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_sync_single_child_fk_on_parent.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_transform.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_trigger.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_unique_behaviour.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_urls.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_utils.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_view.py +0 -0
- {pgsync-6.1.1 → pgsync-7.0.0}/tests/testing_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgsync
|
|
3
|
-
Version:
|
|
3
|
+
Version: 7.0.0
|
|
4
4
|
Summary: Postgres/MySQL/MariaDB to Elasticsearch/OpenSearch sync
|
|
5
5
|
Home-page: https://github.com/toluaina/pgsync
|
|
6
6
|
Author: Tolu Aina
|
|
@@ -33,8 +33,8 @@ License-File: LICENSE
|
|
|
33
33
|
License-File: AUTHORS.rst
|
|
34
34
|
Requires-Dist: async-timeout==5.0.1
|
|
35
35
|
Requires-Dist: backports-datetime-fromisoformat==2.0.3
|
|
36
|
-
Requires-Dist: boto3==1.
|
|
37
|
-
Requires-Dist: botocore==1.
|
|
36
|
+
Requires-Dist: boto3==1.42.2
|
|
37
|
+
Requires-Dist: botocore==1.42.2
|
|
38
38
|
Requires-Dist: certifi==2025.11.12
|
|
39
39
|
Requires-Dist: charset-normalizer==3.4.4
|
|
40
40
|
Requires-Dist: click==8.1.8
|
|
@@ -58,7 +58,7 @@ Requires-Dist: python-dotenv==1.2.1
|
|
|
58
58
|
Requires-Dist: redis==7.0.1
|
|
59
59
|
Requires-Dist: requests==2.32.5
|
|
60
60
|
Requires-Dist: requests-aws4auth==1.3.1
|
|
61
|
-
Requires-Dist: s3transfer==0.
|
|
61
|
+
Requires-Dist: s3transfer==0.16.0
|
|
62
62
|
Requires-Dist: six==1.17.0
|
|
63
63
|
Requires-Dist: sqlalchemy==2.0.44
|
|
64
64
|
Requires-Dist: sqlparse==0.5.4
|
|
@@ -1404,14 +1404,24 @@ def _pg_engine(
|
|
|
1404
1404
|
def pg_execute(
|
|
1405
1405
|
engine: sa.engine.Engine,
|
|
1406
1406
|
statement: sa.sql.Select,
|
|
1407
|
-
values: t.Optional[
|
|
1407
|
+
values: t.Optional[t.Mapping] = None,
|
|
1408
1408
|
options: t.Optional[dict] = None,
|
|
1409
|
-
) ->
|
|
1409
|
+
) -> sa.engine.Result:
|
|
1410
|
+
"""Execute a query statement."""
|
|
1410
1411
|
with engine.connect() as conn:
|
|
1411
1412
|
if options:
|
|
1412
1413
|
conn = conn.execution_options(**options)
|
|
1413
|
-
|
|
1414
|
-
|
|
1414
|
+
|
|
1415
|
+
# Don't pass `None` as values
|
|
1416
|
+
if values is not None:
|
|
1417
|
+
result = conn.execute(statement, values)
|
|
1418
|
+
else:
|
|
1419
|
+
result = conn.execute(statement)
|
|
1420
|
+
|
|
1421
|
+
# If caller did NOT request AUTOCOMMIT, commit the transaction
|
|
1422
|
+
if not (options and options.get("isolation_level") == "AUTOCOMMIT"):
|
|
1423
|
+
conn.commit()
|
|
1424
|
+
return result
|
|
1415
1425
|
|
|
1416
1426
|
|
|
1417
1427
|
def create_schema(database: str, schema: str, echo: bool = False) -> None:
|
|
@@ -25,15 +25,30 @@ class RedisQueue(object):
|
|
|
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"
|
|
28
|
+
self.__db: Redis = Redis.from_url(
|
|
29
|
+
url,
|
|
30
|
+
socket_timeout=REDIS_SOCKET_TIMEOUT,
|
|
31
|
+
retry_on_timeout=REDIS_RETRY_ON_TIMEOUT,
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
self.ping()
|
|
35
|
+
except ConnectionError:
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
def ping(self) -> bool:
|
|
39
|
+
"""
|
|
40
|
+
Ping the Redis server to check connectivity.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if the server responded to PING.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
RedisConnectionError: If the server cannot be reached.
|
|
47
|
+
"""
|
|
28
48
|
try:
|
|
29
|
-
self.__db
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
retry_on_timeout=REDIS_RETRY_ON_TIMEOUT,
|
|
33
|
-
)
|
|
34
|
-
self.__db.ping()
|
|
35
|
-
except ConnectionError as e:
|
|
36
|
-
logger.exception(f"Redis server is not running: {e}")
|
|
49
|
+
return bool(self.__db.ping())
|
|
50
|
+
except ConnectionError as exc:
|
|
51
|
+
logger.exception("Redis server is not reachable when pinging.")
|
|
37
52
|
raise
|
|
38
53
|
|
|
39
54
|
@property
|
|
@@ -28,6 +28,7 @@ from pymysqlreplication.row_event import (
|
|
|
28
28
|
UpdateRowsEvent,
|
|
29
29
|
WriteRowsEvent,
|
|
30
30
|
)
|
|
31
|
+
from redis.exceptions import ConnectionError
|
|
31
32
|
|
|
32
33
|
from pgsync.settings import IS_MYSQL_COMPAT
|
|
33
34
|
|
|
@@ -126,7 +127,7 @@ class Sync(Base, metaclass=Singleton):
|
|
|
126
127
|
self.models, nodes=self.nodes, database=doc["database"]
|
|
127
128
|
)
|
|
128
129
|
if bootstrap:
|
|
129
|
-
self.setup(
|
|
130
|
+
self.setup(polling=polling)
|
|
130
131
|
|
|
131
132
|
if validate:
|
|
132
133
|
self.validate(repl_slots=repl_slots, polling=polling)
|
|
@@ -217,8 +218,9 @@ class Sync(Base, metaclass=Singleton):
|
|
|
217
218
|
# ensure Redis is reachable
|
|
218
219
|
try:
|
|
219
220
|
self.redis.ping()
|
|
220
|
-
except
|
|
221
|
-
raise
|
|
221
|
+
except ConnectionError:
|
|
222
|
+
raise
|
|
223
|
+
|
|
222
224
|
else:
|
|
223
225
|
# ensure the checkpoint dirpath is valid
|
|
224
226
|
if not Path(settings.CHECKPOINT_PATH).exists():
|
|
@@ -320,9 +322,7 @@ class Sync(Base, metaclass=Singleton):
|
|
|
320
322
|
routing=self.routing,
|
|
321
323
|
)
|
|
322
324
|
|
|
323
|
-
def setup(
|
|
324
|
-
self, no_create: bool = False, wal: bool = False, polling: bool = False
|
|
325
|
-
) -> None:
|
|
325
|
+
def setup(self, no_create: bool = False, polling: bool = False) -> None:
|
|
326
326
|
"""Create the database triggers and replication slot.
|
|
327
327
|
Generally bootstrap should not require Redis as it is optional in certain cases.
|
|
328
328
|
"""
|
|
@@ -344,6 +344,12 @@ class Sync(Base, metaclass=Singleton):
|
|
|
344
344
|
|
|
345
345
|
if not polling:
|
|
346
346
|
for schema in self.schemas:
|
|
347
|
+
if schema not in self.tree.schemas:
|
|
348
|
+
logger.warning(
|
|
349
|
+
f"Schema '{schema}' not found in node definitions. Skipping..."
|
|
350
|
+
)
|
|
351
|
+
continue
|
|
352
|
+
|
|
347
353
|
# TODO: move if_not_exists to the function
|
|
348
354
|
if if_not_exists or not self.function_exists(schema):
|
|
349
355
|
|
|
@@ -419,7 +425,9 @@ class Sync(Base, metaclass=Singleton):
|
|
|
419
425
|
self.create_replication_slot(self.__name)
|
|
420
426
|
|
|
421
427
|
def teardown(
|
|
422
|
-
self,
|
|
428
|
+
self,
|
|
429
|
+
drop_view: bool = True,
|
|
430
|
+
polling: bool = False,
|
|
423
431
|
) -> None:
|
|
424
432
|
"""Drop the database triggers and replication slot."""
|
|
425
433
|
if self.is_mysql_compat:
|
|
@@ -440,7 +448,7 @@ class Sync(Base, metaclass=Singleton):
|
|
|
440
448
|
)
|
|
441
449
|
|
|
442
450
|
try:
|
|
443
|
-
if self.
|
|
451
|
+
if self.redis is None:
|
|
444
452
|
raise RuntimeError("Redis is not configured.")
|
|
445
453
|
self.redis.delete()
|
|
446
454
|
except Exception as e:
|
|
@@ -448,6 +456,12 @@ class Sync(Base, metaclass=Singleton):
|
|
|
448
456
|
|
|
449
457
|
if not polling:
|
|
450
458
|
for schema in self.schemas:
|
|
459
|
+
if schema not in self.tree.schemas:
|
|
460
|
+
logger.warning(
|
|
461
|
+
f"Schema '{schema}' not found in node definitions. Skipping..."
|
|
462
|
+
)
|
|
463
|
+
continue
|
|
464
|
+
|
|
451
465
|
tables: t.Set = set()
|
|
452
466
|
for node in self.tree.traverse_breadth_first():
|
|
453
467
|
tables |= set(
|
|
@@ -466,7 +480,7 @@ class Sync(Base, metaclass=Singleton):
|
|
|
466
480
|
self.drop_view(schema)
|
|
467
481
|
self.drop_function(schema)
|
|
468
482
|
|
|
469
|
-
if not
|
|
483
|
+
if not polling:
|
|
470
484
|
self.drop_replication_slot(self.__name)
|
|
471
485
|
|
|
472
486
|
def get_doc_id(self, primary_keys: t.List[str], table: str) -> str:
|
|
@@ -1049,6 +1063,11 @@ class Sync(Base, metaclass=Singleton):
|
|
|
1049
1063
|
if _filters:
|
|
1050
1064
|
filters[self.tree.root.table].extend(_filters)
|
|
1051
1065
|
|
|
1066
|
+
# also check through table with a direct references to root
|
|
1067
|
+
_filters = self._through_node_resolver(node, payloads, _filters)
|
|
1068
|
+
if _filters:
|
|
1069
|
+
filters[self.tree.root.table].extend(_filters)
|
|
1070
|
+
|
|
1052
1071
|
elif node.table in self.tree.tables:
|
|
1053
1072
|
if node.is_root:
|
|
1054
1073
|
for payload in payloads:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgsync
|
|
3
|
-
Version:
|
|
3
|
+
Version: 7.0.0
|
|
4
4
|
Summary: Postgres/MySQL/MariaDB to Elasticsearch/OpenSearch sync
|
|
5
5
|
Home-page: https://github.com/toluaina/pgsync
|
|
6
6
|
Author: Tolu Aina
|
|
@@ -33,8 +33,8 @@ License-File: LICENSE
|
|
|
33
33
|
License-File: AUTHORS.rst
|
|
34
34
|
Requires-Dist: async-timeout==5.0.1
|
|
35
35
|
Requires-Dist: backports-datetime-fromisoformat==2.0.3
|
|
36
|
-
Requires-Dist: boto3==1.
|
|
37
|
-
Requires-Dist: botocore==1.
|
|
36
|
+
Requires-Dist: boto3==1.42.2
|
|
37
|
+
Requires-Dist: botocore==1.42.2
|
|
38
38
|
Requires-Dist: certifi==2025.11.12
|
|
39
39
|
Requires-Dist: charset-normalizer==3.4.4
|
|
40
40
|
Requires-Dist: click==8.1.8
|
|
@@ -58,7 +58,7 @@ Requires-Dist: python-dotenv==1.2.1
|
|
|
58
58
|
Requires-Dist: redis==7.0.1
|
|
59
59
|
Requires-Dist: requests==2.32.5
|
|
60
60
|
Requires-Dist: requests-aws4auth==1.3.1
|
|
61
|
-
Requires-Dist: s3transfer==0.
|
|
61
|
+
Requires-Dist: s3transfer==0.16.0
|
|
62
62
|
Requires-Dist: six==1.17.0
|
|
63
63
|
Requires-Dist: sqlalchemy==2.0.44
|
|
64
64
|
Requires-Dist: sqlparse==0.5.4
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
async-timeout==5.0.1
|
|
2
2
|
backports-datetime-fromisoformat==2.0.3
|
|
3
|
-
boto3==1.
|
|
4
|
-
botocore==1.
|
|
3
|
+
boto3==1.42.2
|
|
4
|
+
botocore==1.42.2
|
|
5
5
|
certifi==2025.11.12
|
|
6
6
|
charset-normalizer==3.4.4
|
|
7
7
|
click==8.1.8
|
|
@@ -25,7 +25,7 @@ python-dotenv==1.2.1
|
|
|
25
25
|
redis==7.0.1
|
|
26
26
|
requests==2.32.5
|
|
27
27
|
requests-aws4auth==1.3.1
|
|
28
|
-
s3transfer==0.
|
|
28
|
+
s3transfer==0.16.0
|
|
29
29
|
six==1.17.0
|
|
30
30
|
sqlalchemy==2.0.44
|
|
31
31
|
sqlparse==0.5.4
|
|
@@ -41,7 +41,7 @@ class TestRedisQueue(object):
|
|
|
41
41
|
mock_get_redis_url.assert_called_once()
|
|
42
42
|
mock_ping.assert_called_once()
|
|
43
43
|
mock_logger.exception.assert_called_once_with(
|
|
44
|
-
"Redis server is not
|
|
44
|
+
"Redis server is not reachable when pinging."
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
def test_qsize(self, mocker):
|
|
@@ -4,12 +4,15 @@ import importlib
|
|
|
4
4
|
import os
|
|
5
5
|
import typing as t
|
|
6
6
|
from collections import namedtuple
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from unittest.mock import Mock
|
|
7
9
|
|
|
8
10
|
import pytest
|
|
9
11
|
from mock import ANY, call, patch
|
|
10
12
|
|
|
11
13
|
from pgsync.base import Base, Payload
|
|
12
14
|
from pgsync.exc import (
|
|
15
|
+
ForeignKeyError,
|
|
13
16
|
InvalidTGOPError,
|
|
14
17
|
PrimaryKeyNotFoundError,
|
|
15
18
|
RDSError,
|
|
@@ -960,7 +963,7 @@ class TestSync(object):
|
|
|
960
963
|
)
|
|
961
964
|
mock_drop_view.assert_called_once_with("public")
|
|
962
965
|
mock_drop_function.assert_called_once_with("public")
|
|
963
|
-
mock_redis_delete.
|
|
966
|
+
mock_redis_delete.assert_called_once()
|
|
964
967
|
assert os.path.exists(sync.checkpoint_file) is False
|
|
965
968
|
|
|
966
969
|
with patch("pgsync.sync.logger") as mock_logger:
|
|
@@ -969,9 +972,6 @@ class TestSync(object):
|
|
|
969
972
|
sync.teardown()
|
|
970
973
|
assert mock_logger.warning.call_args_list == [
|
|
971
974
|
call("Checkpoint file not found: ./.testdb_testdb"),
|
|
972
|
-
call(
|
|
973
|
-
"Could not clear Redis checkpoint queue: Redis is not configured."
|
|
974
|
-
),
|
|
975
975
|
]
|
|
976
976
|
|
|
977
977
|
def test_root(self, sync):
|
|
@@ -1082,3 +1082,371 @@ class TestSync(object):
|
|
|
1082
1082
|
mock_logger.debug.assert_called_once_with(f"_poll_redis: {items}")
|
|
1083
1083
|
mock_time.sleep.assert_called_once_with(settings.REDIS_POLL_INTERVAL)
|
|
1084
1084
|
assert sync.count["redis"] == 2
|
|
1085
|
+
|
|
1086
|
+
def test_insert_op_non_root_uses_foreign_keys_and_resolvers(self, sync):
|
|
1087
|
+
"""
|
|
1088
|
+
Non-root, non-through node:
|
|
1089
|
+
- uses foreign_keys to populate parent filters
|
|
1090
|
+
- uses _root_foreign_key_resolver + _through_node_resolver to populate root filters
|
|
1091
|
+
"""
|
|
1092
|
+
|
|
1093
|
+
# Stub parent and child nodes (duck-typed; don't need the real Node class)
|
|
1094
|
+
parent_node = SimpleNamespace(
|
|
1095
|
+
name="parent",
|
|
1096
|
+
table="parent",
|
|
1097
|
+
parent=None,
|
|
1098
|
+
is_root=False,
|
|
1099
|
+
is_through=False,
|
|
1100
|
+
)
|
|
1101
|
+
node = SimpleNamespace(
|
|
1102
|
+
name="child",
|
|
1103
|
+
table="child",
|
|
1104
|
+
parent=parent_node,
|
|
1105
|
+
is_root=False,
|
|
1106
|
+
is_through=False,
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
# Stub the tree structure
|
|
1110
|
+
sync.tree = SimpleNamespace(
|
|
1111
|
+
tables={"child", "parent", "root"},
|
|
1112
|
+
root=SimpleNamespace(
|
|
1113
|
+
table="root",
|
|
1114
|
+
model=SimpleNamespace(primary_keys=["id"]),
|
|
1115
|
+
parent=None,
|
|
1116
|
+
),
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
# foreign_keys[node.name] and foreign_keys[node.parent.name] share a key
|
|
1120
|
+
# so that the inner equality condition is hit.
|
|
1121
|
+
sync.query_builder = Mock()
|
|
1122
|
+
sync.query_builder.get_foreign_keys.return_value = {
|
|
1123
|
+
"child": ["id"],
|
|
1124
|
+
"parent": ["id"],
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
# Make the root resolvers predictable
|
|
1128
|
+
sync._root_foreign_key_resolver = Mock(return_value=[{"root_id": 1}])
|
|
1129
|
+
|
|
1130
|
+
def through_node_resolver(node_arg, payloads_arg, filters_arg):
|
|
1131
|
+
# emulate "extend" behaviour
|
|
1132
|
+
filters_arg.append({"root_id": 2})
|
|
1133
|
+
return filters_arg
|
|
1134
|
+
|
|
1135
|
+
sync._through_node_resolver = through_node_resolver
|
|
1136
|
+
|
|
1137
|
+
filters: dict[str, t.List[dict]] = {
|
|
1138
|
+
"parent": [],
|
|
1139
|
+
"root": [],
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
payloads: t.List[Payload] = [
|
|
1143
|
+
Payload(
|
|
1144
|
+
tg_op="INSERT",
|
|
1145
|
+
table="child",
|
|
1146
|
+
new={"id": 99}, # matches foreign_keys["child"] and ["parent"]
|
|
1147
|
+
)
|
|
1148
|
+
]
|
|
1149
|
+
|
|
1150
|
+
result = sync._insert_op(node, filters, payloads)
|
|
1151
|
+
|
|
1152
|
+
# 1) Parent should get a filter derived from the foreign key
|
|
1153
|
+
assert {"id": 99} in result["parent"]
|
|
1154
|
+
|
|
1155
|
+
# 2) Root should get filters coming back from both resolvers
|
|
1156
|
+
assert {"root_id": 1} in result["root"]
|
|
1157
|
+
assert {"root_id": 2} in result["root"]
|
|
1158
|
+
|
|
1159
|
+
# Sanity: we didn't create any unexpected tables in the filters
|
|
1160
|
+
assert set(result.keys()) == {"parent", "root"}
|
|
1161
|
+
|
|
1162
|
+
def test_insert_op_non_root_falls_back_to__get_foreign_keys(self, sync):
|
|
1163
|
+
"""
|
|
1164
|
+
When get_foreign_keys raises ForeignKeyError, _get_foreign_keys is used.
|
|
1165
|
+
"""
|
|
1166
|
+
|
|
1167
|
+
parent_node = SimpleNamespace(
|
|
1168
|
+
name="parent",
|
|
1169
|
+
table="parent",
|
|
1170
|
+
parent=None,
|
|
1171
|
+
is_root=False,
|
|
1172
|
+
is_through=False,
|
|
1173
|
+
)
|
|
1174
|
+
node = SimpleNamespace(
|
|
1175
|
+
name="child",
|
|
1176
|
+
table="child",
|
|
1177
|
+
parent=parent_node,
|
|
1178
|
+
is_root=False,
|
|
1179
|
+
is_through=False,
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
sync.tree = SimpleNamespace(
|
|
1183
|
+
tables={"child", "parent", "root"},
|
|
1184
|
+
root=SimpleNamespace(
|
|
1185
|
+
table="root",
|
|
1186
|
+
model=SimpleNamespace(primary_keys=["id"]),
|
|
1187
|
+
parent=None,
|
|
1188
|
+
),
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
sync.query_builder = Mock()
|
|
1192
|
+
# Primary method fails...
|
|
1193
|
+
sync.query_builder.get_foreign_keys.side_effect = ForeignKeyError(
|
|
1194
|
+
"no fk"
|
|
1195
|
+
)
|
|
1196
|
+
# ...fallback provides the mapping actually used
|
|
1197
|
+
sync.query_builder._get_foreign_keys.return_value = {
|
|
1198
|
+
"child": ["id"],
|
|
1199
|
+
"parent": ["id"],
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
sync._root_foreign_key_resolver = Mock(return_value=[])
|
|
1203
|
+
sync._through_node_resolver = Mock(return_value=[])
|
|
1204
|
+
|
|
1205
|
+
filters: dict[str, t.List[dict]] = {
|
|
1206
|
+
"parent": [],
|
|
1207
|
+
"root": [],
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
payloads: t.List[Payload] = [
|
|
1211
|
+
Payload(
|
|
1212
|
+
tg_op="INSERT",
|
|
1213
|
+
table="child",
|
|
1214
|
+
new={"id": 123},
|
|
1215
|
+
)
|
|
1216
|
+
]
|
|
1217
|
+
|
|
1218
|
+
result = sync._insert_op(node, filters, payloads)
|
|
1219
|
+
|
|
1220
|
+
# Parent filter must come from fallback mapping
|
|
1221
|
+
assert {"id": 123} in result["parent"]
|
|
1222
|
+
|
|
1223
|
+
sync.query_builder.get_foreign_keys.assert_called_once()
|
|
1224
|
+
sync.query_builder._get_foreign_keys.assert_called_once()
|
|
1225
|
+
|
|
1226
|
+
def test_insert_op_through_node_populates_parent_and_root(self, sync):
|
|
1227
|
+
"""
|
|
1228
|
+
Through node:
|
|
1229
|
+
- Uses FKs to populate parent filters
|
|
1230
|
+
- Uses _root_primary_key_resolver for parent and grandparent
|
|
1231
|
+
- Uses _through_node_resolver for additional root filters
|
|
1232
|
+
"""
|
|
1233
|
+
grandparent = SimpleNamespace(
|
|
1234
|
+
name="grand",
|
|
1235
|
+
table="grand",
|
|
1236
|
+
parent=None,
|
|
1237
|
+
is_root=False,
|
|
1238
|
+
is_through=False,
|
|
1239
|
+
)
|
|
1240
|
+
parent = SimpleNamespace(
|
|
1241
|
+
name="parent",
|
|
1242
|
+
table="parent",
|
|
1243
|
+
parent=grandparent,
|
|
1244
|
+
is_root=False,
|
|
1245
|
+
is_through=False,
|
|
1246
|
+
)
|
|
1247
|
+
node = SimpleNamespace(
|
|
1248
|
+
name="through",
|
|
1249
|
+
table="through_table",
|
|
1250
|
+
parent=parent,
|
|
1251
|
+
is_root=False,
|
|
1252
|
+
is_through=True,
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
sync.tree = SimpleNamespace(
|
|
1256
|
+
tables={"grand", "parent", "through_table", "root"},
|
|
1257
|
+
root=SimpleNamespace(
|
|
1258
|
+
table="root",
|
|
1259
|
+
model=SimpleNamespace(primary_keys=["id"]),
|
|
1260
|
+
parent=None,
|
|
1261
|
+
),
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
# FKs used for the "if column in payload.data" part
|
|
1265
|
+
sync.query_builder = SimpleNamespace()
|
|
1266
|
+
sync.query_builder.get_foreign_keys = lambda parent_node, child_node: {
|
|
1267
|
+
"through": ["parent_id"],
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
# Track calls so we know we hit parent and grandparent
|
|
1271
|
+
resolver_calls: list[str] = []
|
|
1272
|
+
|
|
1273
|
+
def root_primary_key_resolver(node_arg, payloads_arg, filters_arg):
|
|
1274
|
+
"""
|
|
1275
|
+
Emulate real behaviour: mutate filters_arg and return it.
|
|
1276
|
+
"""
|
|
1277
|
+
resolver_calls.append(node_arg.name)
|
|
1278
|
+
if node_arg is parent:
|
|
1279
|
+
filters_arg.append({"id": 10})
|
|
1280
|
+
elif node_arg is grandparent:
|
|
1281
|
+
filters_arg.append({"id": 20})
|
|
1282
|
+
return filters_arg
|
|
1283
|
+
|
|
1284
|
+
def through_node_resolver(node_arg, payloads_arg, filters_arg):
|
|
1285
|
+
"""
|
|
1286
|
+
Also mutates filters_arg and returns it.
|
|
1287
|
+
"""
|
|
1288
|
+
filters_arg.append({"id": 30})
|
|
1289
|
+
return filters_arg
|
|
1290
|
+
|
|
1291
|
+
sync._root_primary_key_resolver = root_primary_key_resolver
|
|
1292
|
+
sync._through_node_resolver = through_node_resolver
|
|
1293
|
+
|
|
1294
|
+
filters: dict[str, t.List[dict]] = {
|
|
1295
|
+
"parent": [],
|
|
1296
|
+
"root": [],
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
payloads: t.List[Payload] = [
|
|
1300
|
+
Payload(
|
|
1301
|
+
tg_op="INSERT",
|
|
1302
|
+
table="through_table",
|
|
1303
|
+
new={"parent_id": 5},
|
|
1304
|
+
)
|
|
1305
|
+
]
|
|
1306
|
+
|
|
1307
|
+
result = sync._insert_op(node, filters, payloads)
|
|
1308
|
+
|
|
1309
|
+
# Parent got populated from FK
|
|
1310
|
+
assert {"parent_id": 5} in result["parent"]
|
|
1311
|
+
|
|
1312
|
+
root_filters = result["root"]
|
|
1313
|
+
|
|
1314
|
+
# Root got filters from both root resolvers and through resolver
|
|
1315
|
+
assert {"id": 10} in root_filters
|
|
1316
|
+
assert {"id": 20} in root_filters
|
|
1317
|
+
assert {"id": 30} in root_filters
|
|
1318
|
+
|
|
1319
|
+
# We called the resolver for both parent and grandparent
|
|
1320
|
+
assert resolver_calls == ["parent", "grand"]
|
|
1321
|
+
|
|
1322
|
+
def test_insert_op_ignores_node_not_in_tree_tables(self, sync):
|
|
1323
|
+
"""
|
|
1324
|
+
When node.table is not in self.tree.tables and node.is_through is False,
|
|
1325
|
+
the function should return filters unchanged.
|
|
1326
|
+
"""
|
|
1327
|
+
|
|
1328
|
+
node = SimpleNamespace(
|
|
1329
|
+
name="other",
|
|
1330
|
+
table="other_table",
|
|
1331
|
+
parent=None,
|
|
1332
|
+
is_root=False,
|
|
1333
|
+
is_through=False,
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
sync.tree = SimpleNamespace(
|
|
1337
|
+
tables={"book", "publisher"}, # "other_table" not listed
|
|
1338
|
+
root=SimpleNamespace(
|
|
1339
|
+
table="book",
|
|
1340
|
+
model=SimpleNamespace(primary_keys=["isbn"]),
|
|
1341
|
+
parent=None,
|
|
1342
|
+
),
|
|
1343
|
+
)
|
|
1344
|
+
|
|
1345
|
+
original_filters = {"book": [{"isbn": "001"}]}
|
|
1346
|
+
filters = {"book": [{"isbn": "001"}]}
|
|
1347
|
+
|
|
1348
|
+
payloads = [] # no payloads; nothing should happen
|
|
1349
|
+
|
|
1350
|
+
result = sync._insert_op(node, filters, payloads)
|
|
1351
|
+
|
|
1352
|
+
# Same content, no mutation beyond what was already there
|
|
1353
|
+
assert result == original_filters
|
|
1354
|
+
|
|
1355
|
+
def test_insert_op_root_node_with_composite_primary_key(self, sync):
|
|
1356
|
+
"""
|
|
1357
|
+
Root node: ensure we correctly build filters for composite PKs.
|
|
1358
|
+
"""
|
|
1359
|
+
|
|
1360
|
+
# Root node
|
|
1361
|
+
node = SimpleNamespace(
|
|
1362
|
+
name="order",
|
|
1363
|
+
table="order",
|
|
1364
|
+
parent=None,
|
|
1365
|
+
is_root=True,
|
|
1366
|
+
is_through=False,
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
sync.tree = SimpleNamespace(
|
|
1370
|
+
tables={"order"},
|
|
1371
|
+
root=SimpleNamespace(
|
|
1372
|
+
table="order",
|
|
1373
|
+
model=SimpleNamespace(primary_keys=["id", "version"]),
|
|
1374
|
+
parent=None,
|
|
1375
|
+
),
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
filters: dict[str, t.List[dict]] = {"order": []}
|
|
1379
|
+
|
|
1380
|
+
payloads: t.List[Payload] = [
|
|
1381
|
+
Payload(
|
|
1382
|
+
tg_op="INSERT",
|
|
1383
|
+
table="order",
|
|
1384
|
+
new={"id": 1, "version": 2, "other": "ignored"},
|
|
1385
|
+
)
|
|
1386
|
+
]
|
|
1387
|
+
|
|
1388
|
+
result = sync._insert_op(node, filters, payloads)
|
|
1389
|
+
|
|
1390
|
+
# Should only contain PK fields, not extra ones
|
|
1391
|
+
assert result == {"order": [{"id": 1, "version": 2}]}
|
|
1392
|
+
|
|
1393
|
+
def test_insert_op_non_root_with_mismatched_foreign_keys(self, sync):
|
|
1394
|
+
"""
|
|
1395
|
+
Non-root, non-through node where FK names don't match between child/parent.
|
|
1396
|
+
- We should not add anything to parent filters.
|
|
1397
|
+
"""
|
|
1398
|
+
|
|
1399
|
+
parent_node = SimpleNamespace(
|
|
1400
|
+
name="parent",
|
|
1401
|
+
table="parent",
|
|
1402
|
+
parent=None,
|
|
1403
|
+
is_root=False,
|
|
1404
|
+
is_through=False,
|
|
1405
|
+
)
|
|
1406
|
+
node = SimpleNamespace(
|
|
1407
|
+
name="child",
|
|
1408
|
+
table="child",
|
|
1409
|
+
parent=parent_node,
|
|
1410
|
+
is_root=False,
|
|
1411
|
+
is_through=False,
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
sync.tree = SimpleNamespace(
|
|
1415
|
+
tables={"parent", "child", "root"},
|
|
1416
|
+
root=SimpleNamespace(
|
|
1417
|
+
table="root",
|
|
1418
|
+
model=SimpleNamespace(primary_keys=["id"]),
|
|
1419
|
+
parent=None,
|
|
1420
|
+
),
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
sync.query_builder = Mock()
|
|
1424
|
+
# Note: no matching key names between child and parent
|
|
1425
|
+
sync.query_builder.get_foreign_keys.return_value = {
|
|
1426
|
+
"child": ["child_id"],
|
|
1427
|
+
"parent": ["parent_id"],
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
# No root/through filters for this test
|
|
1431
|
+
sync._root_foreign_key_resolver = Mock(return_value=[])
|
|
1432
|
+
sync._through_node_resolver = Mock(return_value=[])
|
|
1433
|
+
|
|
1434
|
+
filters: dict[str, t.List[dict]] = {
|
|
1435
|
+
"parent": [],
|
|
1436
|
+
"root": [],
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
payloads: t.List[Payload] = [
|
|
1440
|
+
Payload(
|
|
1441
|
+
tg_op="INSERT",
|
|
1442
|
+
table="child",
|
|
1443
|
+
new={"child_id": 42},
|
|
1444
|
+
)
|
|
1445
|
+
]
|
|
1446
|
+
|
|
1447
|
+
result = sync._insert_op(node, filters, payloads)
|
|
1448
|
+
|
|
1449
|
+
# Parent remains empty because no FK name match
|
|
1450
|
+
assert result["parent"] == []
|
|
1451
|
+
# Root remains unchanged because resolvers returned empty
|
|
1452
|
+
assert result["root"] == []
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|