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.
Files changed (75) hide show
  1. {pgsync-6.1.1 → pgsync-7.0.0}/PKG-INFO +4 -4
  2. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/__init__.py +1 -1
  3. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/base.py +14 -4
  4. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/redisqueue.py +23 -8
  5. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/sync.py +28 -9
  6. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/trigger.py +1 -1
  7. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/PKG-INFO +4 -4
  8. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/requires.txt +3 -3
  9. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_redisqueue.py +1 -1
  10. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_sync.py +372 -4
  11. {pgsync-6.1.1 → pgsync-7.0.0}/AUTHORS.rst +0 -0
  12. {pgsync-6.1.1 → pgsync-7.0.0}/CONTRIBUTING.rst +0 -0
  13. {pgsync-6.1.1 → pgsync-7.0.0}/HISTORY.rst +0 -0
  14. {pgsync-6.1.1 → pgsync-7.0.0}/LICENSE +0 -0
  15. {pgsync-6.1.1 → pgsync-7.0.0}/MANIFEST.in +0 -0
  16. {pgsync-6.1.1 → pgsync-7.0.0}/README.md +0 -0
  17. {pgsync-6.1.1 → pgsync-7.0.0}/README.rst +0 -0
  18. {pgsync-6.1.1 → pgsync-7.0.0}/bin/bootstrap +0 -0
  19. {pgsync-6.1.1 → pgsync-7.0.0}/bin/parallel_sync +0 -0
  20. {pgsync-6.1.1 → pgsync-7.0.0}/bin/pgsync +0 -0
  21. {pgsync-6.1.1 → pgsync-7.0.0}/docs/Makefile +0 -0
  22. {pgsync-6.1.1 → pgsync-7.0.0}/docs/authors.rst +0 -0
  23. {pgsync-6.1.1 → pgsync-7.0.0}/docs/changelog.rst +0 -0
  24. {pgsync-6.1.1 → pgsync-7.0.0}/docs/conf.py +0 -0
  25. {pgsync-6.1.1 → pgsync-7.0.0}/docs/contributing.rst +0 -0
  26. {pgsync-6.1.1 → pgsync-7.0.0}/docs/history.rst +0 -0
  27. {pgsync-6.1.1 → pgsync-7.0.0}/docs/index.rst +0 -0
  28. {pgsync-6.1.1 → pgsync-7.0.0}/docs/installation.rst +0 -0
  29. {pgsync-6.1.1 → pgsync-7.0.0}/docs/logo.png +0 -0
  30. {pgsync-6.1.1 → pgsync-7.0.0}/docs/make.bat +0 -0
  31. {pgsync-6.1.1 → pgsync-7.0.0}/docs/readme.rst +0 -0
  32. {pgsync-6.1.1 → pgsync-7.0.0}/docs/usage.rst +0 -0
  33. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/constants.py +0 -0
  34. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/exc.py +0 -0
  35. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/helper.py +0 -0
  36. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/node.py +0 -0
  37. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/plugin.py +0 -0
  38. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/querybuilder.py +0 -0
  39. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/search_client.py +0 -0
  40. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/settings.py +0 -0
  41. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/singleton.py +0 -0
  42. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/transform.py +0 -0
  43. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/urls.py +0 -0
  44. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/utils.py +0 -0
  45. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync/view.py +0 -0
  46. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/SOURCES.txt +0 -0
  47. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/dependency_links.txt +0 -0
  48. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/not-zip-safe +0 -0
  49. {pgsync-6.1.1 → pgsync-7.0.0}/pgsync.egg-info/top_level.txt +0 -0
  50. {pgsync-6.1.1 → pgsync-7.0.0}/pyproject.toml +0 -0
  51. {pgsync-6.1.1 → pgsync-7.0.0}/setup.cfg +0 -0
  52. {pgsync-6.1.1 → pgsync-7.0.0}/setup.py +0 -0
  53. {pgsync-6.1.1 → pgsync-7.0.0}/tests/__init__.py +0 -0
  54. {pgsync-6.1.1 → pgsync-7.0.0}/tests/conftest.py +0 -0
  55. {pgsync-6.1.1 → pgsync-7.0.0}/tests/fixtures/schema.json +0 -0
  56. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_base.py +0 -0
  57. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_constants.py +0 -0
  58. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_env_vars.py +0 -0
  59. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_helper.py +0 -0
  60. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_log_handlers.py +0 -0
  61. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_node.py +0 -0
  62. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_query_builder.py +0 -0
  63. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_search_client.py +0 -0
  64. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_settings.py +0 -0
  65. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_sync_nested_children.py +0 -0
  66. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_sync_root.py +0 -0
  67. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_sync_single_child_fk_on_child.py +0 -0
  68. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_sync_single_child_fk_on_parent.py +0 -0
  69. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_transform.py +0 -0
  70. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_trigger.py +0 -0
  71. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_unique_behaviour.py +0 -0
  72. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_urls.py +0 -0
  73. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_utils.py +0 -0
  74. {pgsync-6.1.1 → pgsync-7.0.0}/tests/test_view.py +0 -0
  75. {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: 6.1.1
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.41.5
37
- Requires-Dist: botocore==1.41.5
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.15.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
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = "Tolu Aina"
4
4
  __email__ = "tolu@pgsync.com"
5
- __version__ = "6.1.1"
5
+ __version__ = "7.0.0"
@@ -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[list] = None,
1407
+ values: t.Optional[t.Mapping] = None,
1408
1408
  options: t.Optional[dict] = None,
1409
- ) -> None:
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
- conn.execute(statement, values)
1414
- conn.commit()
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: Redis = Redis.from_url(
30
- url,
31
- socket_timeout=REDIS_SOCKET_TIMEOUT,
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(wal=wal, polling=polling)
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 Exception as e:
221
- raise RuntimeError(f"Cannot reach Redis: {e}")
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, drop_view: bool = True, polling: bool = False, wal: bool = False
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._redis is None:
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 wal:
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:
@@ -27,7 +27,7 @@ BEGIN
27
27
 
28
28
  IF TG_OP = 'DELETE' THEN
29
29
 
30
- SELECT primary_keys INTO _primary_keys
30
+ SELECT primary_keys, indices INTO _primary_keys, _indices
31
31
  FROM {MATERIALIZED_VIEW}
32
32
  WHERE table_name = TG_TABLE_NAME;
33
33
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsync
3
- Version: 6.1.1
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.41.5
37
- Requires-Dist: botocore==1.41.5
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.15.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.41.5
4
- botocore==1.41.5
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.15.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 running: pong"
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.assert_not_called()
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