dbextractors 1.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 (240) hide show
  1. dbextractors-1.0.0/ARCHITECTURE.md +195 -0
  2. dbextractors-1.0.0/CHANGELOG.md +377 -0
  3. dbextractors-1.0.0/CODE_OF_CONDUCT.md +17 -0
  4. dbextractors-1.0.0/CONTRIBUTING.md +99 -0
  5. dbextractors-1.0.0/LICENSE +202 -0
  6. dbextractors-1.0.0/MANIFEST.in +39 -0
  7. dbextractors-1.0.0/NOTICE +26 -0
  8. dbextractors-1.0.0/PKG-INFO +783 -0
  9. dbextractors-1.0.0/README.md +741 -0
  10. dbextractors-1.0.0/SECURITY.md +101 -0
  11. dbextractors-1.0.0/docs/data/mage_clean_name.py.txt +66 -0
  12. dbextractors-1.0.0/docs/data/mage_sql_reserved_words.json +832 -0
  13. dbextractors-1.0.0/docs/golden-test.md +247 -0
  14. dbextractors-1.0.0/docs/legacy-compat.md +285 -0
  15. dbextractors-1.0.0/docs/mage-loader-block.md +83 -0
  16. dbextractors-1.0.0/docs/partitioning-existing-table.md +104 -0
  17. dbextractors-1.0.0/pyproject.toml +150 -0
  18. dbextractors-1.0.0/setup.cfg +4 -0
  19. dbextractors-1.0.0/src/dbextractors/__init__.py +23 -0
  20. dbextractors-1.0.0/src/dbextractors/core/__init__.py +9 -0
  21. dbextractors-1.0.0/src/dbextractors/core/_reserved_words.py +846 -0
  22. dbextractors-1.0.0/src/dbextractors/core/coerce.py +1064 -0
  23. dbextractors-1.0.0/src/dbextractors/core/config.py +770 -0
  24. dbextractors-1.0.0/src/dbextractors/core/hashing.py +293 -0
  25. dbextractors-1.0.0/src/dbextractors/core/logging.py +102 -0
  26. dbextractors-1.0.0/src/dbextractors/core/naming.py +278 -0
  27. dbextractors-1.0.0/src/dbextractors/core/partitioning.py +510 -0
  28. dbextractors-1.0.0/src/dbextractors/core/reading.py +174 -0
  29. dbextractors-1.0.0/src/dbextractors/core/retry.py +108 -0
  30. dbextractors-1.0.0/src/dbextractors/core/secrets.py +165 -0
  31. dbextractors-1.0.0/src/dbextractors/core/status.py +179 -0
  32. dbextractors-1.0.0/src/dbextractors/core/strategies/__init__.py +20 -0
  33. dbextractors-1.0.0/src/dbextractors/core/strategies/base.py +497 -0
  34. dbextractors-1.0.0/src/dbextractors/core/strategies/full.py +699 -0
  35. dbextractors-1.0.0/src/dbextractors/core/strategies/full_by_source.py +574 -0
  36. dbextractors-1.0.0/src/dbextractors/core/strategies/hash_diff.py +591 -0
  37. dbextractors-1.0.0/src/dbextractors/core/strategies/id_watermark.py +240 -0
  38. dbextractors-1.0.0/src/dbextractors/core/strategies/incremental.py +567 -0
  39. dbextractors-1.0.0/src/dbextractors/core/strategies/parent_incremental.py +410 -0
  40. dbextractors-1.0.0/src/dbextractors/core/target_conn.py +188 -0
  41. dbextractors-1.0.0/src/dbextractors/core/target_pg.py +1948 -0
  42. dbextractors-1.0.0/src/dbextractors/core/tunnel.py +496 -0
  43. dbextractors-1.0.0/src/dbextractors/dialects/__init__.py +48 -0
  44. dbextractors-1.0.0/src/dbextractors/dialects/base.py +503 -0
  45. dbextractors-1.0.0/src/dbextractors/dialects/firebird.py +642 -0
  46. dbextractors-1.0.0/src/dbextractors/dialects/mssql.py +576 -0
  47. dbextractors-1.0.0/src/dbextractors/dialects/mysql.py +337 -0
  48. dbextractors-1.0.0/src/dbextractors/dialects/postgres.py +357 -0
  49. dbextractors-1.0.0/src/dbextractors/entrypoint.py +764 -0
  50. dbextractors-1.0.0/src/dbextractors/golden/__init__.py +37 -0
  51. dbextractors-1.0.0/src/dbextractors/golden/cli.py +266 -0
  52. dbextractors-1.0.0/src/dbextractors/golden/compare.py +757 -0
  53. dbextractors-1.0.0/src/dbextractors/golden/deviations.py +229 -0
  54. dbextractors-1.0.0/src/dbextractors/golden/introspect.py +133 -0
  55. dbextractors-1.0.0/src/dbextractors/golden/model.py +221 -0
  56. dbextractors-1.0.0/src/dbextractors/golden/progress.py +134 -0
  57. dbextractors-1.0.0/src/dbextractors/golden/report.py +275 -0
  58. dbextractors-1.0.0/src/dbextractors/golden/runners.py +247 -0
  59. dbextractors-1.0.0/src/dbextractors/golden/scratch.py +127 -0
  60. dbextractors-1.0.0/src/dbextractors/golden/session.py +249 -0
  61. dbextractors-1.0.0/src/dbextractors/golden/sqlgen.py +266 -0
  62. dbextractors-1.0.0/src/dbextractors/py.typed +0 -0
  63. dbextractors-1.0.0/src/dbextractors.egg-info/PKG-INFO +783 -0
  64. dbextractors-1.0.0/src/dbextractors.egg-info/SOURCES.txt +238 -0
  65. dbextractors-1.0.0/src/dbextractors.egg-info/dependency_links.txt +1 -0
  66. dbextractors-1.0.0/src/dbextractors.egg-info/entry_points.txt +2 -0
  67. dbextractors-1.0.0/src/dbextractors.egg-info/requires.txt +28 -0
  68. dbextractors-1.0.0/src/dbextractors.egg-info/top_level.txt +1 -0
  69. dbextractors-1.0.0/tests/coerce/test_characterization_coerce.py +628 -0
  70. dbextractors-1.0.0/tests/coerce/test_int64_precision.py +207 -0
  71. dbextractors-1.0.0/tests/coerce/test_strict_integer_precision.py +207 -0
  72. dbextractors-1.0.0/tests/conftest.py +63 -0
  73. dbextractors-1.0.0/tests/core/test_config.py +1009 -0
  74. dbextractors-1.0.0/tests/core/test_credential_leaks.py +634 -0
  75. dbextractors-1.0.0/tests/core/test_entrypoint.py +585 -0
  76. dbextractors-1.0.0/tests/core/test_logging.py +187 -0
  77. dbextractors-1.0.0/tests/core/test_partitioning.py +188 -0
  78. dbextractors-1.0.0/tests/core/test_reading.py +182 -0
  79. dbextractors-1.0.0/tests/core/test_required_columns.py +159 -0
  80. dbextractors-1.0.0/tests/core/test_retry.py +438 -0
  81. dbextractors-1.0.0/tests/core/test_secrets.py +206 -0
  82. dbextractors-1.0.0/tests/core/test_status.py +184 -0
  83. dbextractors-1.0.0/tests/core/test_target_conn.py +494 -0
  84. dbextractors-1.0.0/tests/core/test_tunnel.py +660 -0
  85. dbextractors-1.0.0/tests/dialects/conftest.py +92 -0
  86. dbextractors-1.0.0/tests/dialects/source_db.py +106 -0
  87. dbextractors-1.0.0/tests/dialects/test_firebird.py +732 -0
  88. dbextractors-1.0.0/tests/dialects/test_mssql.py +484 -0
  89. dbextractors-1.0.0/tests/dialects/test_mysql.py +331 -0
  90. dbextractors-1.0.0/tests/dialects/test_postgres.py +130 -0
  91. dbextractors-1.0.0/tests/dialects/test_postgres_db.py +216 -0
  92. dbextractors-1.0.0/tests/dialects/test_source_db.py +910 -0
  93. dbextractors-1.0.0/tests/fakes.py +398 -0
  94. dbextractors-1.0.0/tests/fixtures/oracle/A-my-ld/compute_hash_columns.json +531 -0
  95. dbextractors-1.0.0/tests/fixtures/oracle/A-my-ld/create_column_mapping.json +249 -0
  96. dbextractors-1.0.0/tests/fixtures/oracle/A-my-ld/wait_for_port.json +24 -0
  97. dbextractors-1.0.0/tests/fixtures/oracle/A-my-ld/with_retry.json +42 -0
  98. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/_clean.json +30 -0
  99. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/_fmt_int_for_csv.json +1587 -0
  100. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/_is_pd_na.json +1477 -0
  101. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/_normalize_carriage_returns.json +321 -0
  102. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/_pk_to_str.json +141 -0
  103. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/add_hash_and_timestamp.json +2618 -0
  104. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/align_df_columns_to_db.json +692 -0
  105. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/compute_hash_columns.json +531 -0
  106. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/create_column_mapping.json +1199 -0
  107. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/decode_bytes_columns.json +294 -0
  108. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/drop_mage_cols.json +123 -0
  109. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/ensure_private_key_permissions.json +81 -0
  110. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/find_free_port.json +11 -0
  111. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/fix_column_values.json +378 -0
  112. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/fix_invalid_dates.json +580 -0
  113. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/normalize_private_key_contents.json +126 -0
  114. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/prepare_export_df.json +312 -0
  115. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/replace_pdna_with_none.json +282 -0
  116. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/sanitize_integer_columns.json +419 -0
  117. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/to_int_or_na.json +1613 -0
  118. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/to_int_or_none.json +1621 -0
  119. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/to_jsonb.json +674 -0
  120. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/to_str_or_none.json +1491 -0
  121. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/update_stats.json +1066 -0
  122. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/wait_for_port.json +68 -0
  123. dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/with_retry.json +210 -0
  124. dbextractors-1.0.0/tests/fixtures/oracle/A-pg/_pk_to_str.json +141 -0
  125. dbextractors-1.0.0/tests/fixtures/oracle/A-pg/compute_hash_columns.json +531 -0
  126. dbextractors-1.0.0/tests/fixtures/oracle/A-pg/create_column_mapping.json +249 -0
  127. dbextractors-1.0.0/tests/fixtures/oracle/A-pg/fix_column_values.json +754 -0
  128. dbextractors-1.0.0/tests/fixtures/oracle/A-pg/wait_for_port.json +24 -0
  129. dbextractors-1.0.0/tests/fixtures/oracle/A-pg/with_retry.json +42 -0
  130. dbextractors-1.0.0/tests/fixtures/oracle/A-pg-ld/compute_hash_columns.json +531 -0
  131. dbextractors-1.0.0/tests/fixtures/oracle/A-pg-ld/create_column_mapping.json +249 -0
  132. dbextractors-1.0.0/tests/fixtures/oracle/A-pg-ld/quote_ident.json +93 -0
  133. dbextractors-1.0.0/tests/fixtures/oracle/A-pg-ld/wait_for_port.json +24 -0
  134. dbextractors-1.0.0/tests/fixtures/oracle/A-pg-ld/with_retry.json +42 -0
  135. dbextractors-1.0.0/tests/fixtures/oracle/B-fbird/create_column_mapping.json +249 -0
  136. dbextractors-1.0.0/tests/fixtures/oracle/B-fbird/fix_column_values.json +378 -0
  137. dbextractors-1.0.0/tests/fixtures/oracle/B-fbird/update_stats.json +1066 -0
  138. dbextractors-1.0.0/tests/fixtures/oracle/B-fbird/wait_for_port.json +24 -0
  139. dbextractors-1.0.0/tests/fixtures/oracle/B-fbird/with_retry.json +42 -0
  140. dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/_decode_value.json +78 -0
  141. dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/_pk_to_str.json +141 -0
  142. dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/compute_hash_columns.json +531 -0
  143. dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/create_column_mapping.json +249 -0
  144. dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/fix_column_values.json +378 -0
  145. dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/update_stats.json +1066 -0
  146. dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/wait_for_port.json +24 -0
  147. dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/with_retry.json +42 -0
  148. dbextractors-1.0.0/tests/fixtures/oracle/B-my-uni/compute_hash_columns.json +531 -0
  149. dbextractors-1.0.0/tests/fixtures/oracle/B-my-uni/create_column_mapping.json +249 -0
  150. dbextractors-1.0.0/tests/fixtures/oracle/B-my-uni/wait_for_port.json +24 -0
  151. dbextractors-1.0.0/tests/fixtures/oracle/B-my-uni/with_retry.json +42 -0
  152. dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/_clean.json +678 -0
  153. dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/_pk_to_str.json +141 -0
  154. dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/compute_hash_columns.json +531 -0
  155. dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/create_column_mapping.json +249 -0
  156. dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/fix_column_values.json +378 -0
  157. dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/update_stats.json +1066 -0
  158. dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/wait_for_port.json +24 -0
  159. dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/with_retry.json +42 -0
  160. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/_pk_to_str.json +141 -0
  161. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/compute_hash_columns.json +531 -0
  162. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/create_column_mapping.json +249 -0
  163. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/fix_column_values.json +754 -0
  164. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/to_bool.json +1603 -0
  165. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/to_date_str.json +1661 -0
  166. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/to_datetime_str.json +1661 -0
  167. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/to_jsonb.json +674 -0
  168. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/to_time_str.json +238 -0
  169. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/wait_for_port.json +24 -0
  170. dbextractors-1.0.0/tests/fixtures/oracle/B-pg/with_retry.json +42 -0
  171. dbextractors-1.0.0/tests/fixtures/oracle/B-pg-ld/compute_hash_columns.json +531 -0
  172. dbextractors-1.0.0/tests/fixtures/oracle/B-pg-ld/create_column_mapping.json +249 -0
  173. dbextractors-1.0.0/tests/fixtures/oracle/B-pg-ld/wait_for_port.json +24 -0
  174. dbextractors-1.0.0/tests/fixtures/oracle/B-pg-ld/with_retry.json +42 -0
  175. dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/_pk_to_str.json +141 -0
  176. dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/compute_hash_columns.json +531 -0
  177. dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/create_column_mapping.json +249 -0
  178. dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/fix_column_values.json +378 -0
  179. dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/is_truthy.json +350 -0
  180. dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/update_stats.json +1066 -0
  181. dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/wait_for_port.json +24 -0
  182. dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/with_retry.json +42 -0
  183. dbextractors-1.0.0/tests/fixtures/oracle/C-my-uni/compute_hash_columns.json +531 -0
  184. dbextractors-1.0.0/tests/fixtures/oracle/C-my-uni/create_column_mapping.json +249 -0
  185. dbextractors-1.0.0/tests/fixtures/oracle/C-my-uni/wait_for_port.json +24 -0
  186. dbextractors-1.0.0/tests/fixtures/oracle/C-my-uni/with_retry.json +42 -0
  187. dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/_pk_to_str.json +141 -0
  188. dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/compute_hash_columns.json +531 -0
  189. dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/create_column_mapping.json +249 -0
  190. dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/fix_column_values.json +378 -0
  191. dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/update_stats.json +1066 -0
  192. dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/wait_for_port.json +24 -0
  193. dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/with_retry.json +42 -0
  194. dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/_pk_to_str.json +141 -0
  195. dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/compute_hash_columns.json +531 -0
  196. dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/create_column_mapping.json +249 -0
  197. dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/fix_column_values.json +378 -0
  198. dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/update_stats.json +1066 -0
  199. dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/wait_for_port.json +24 -0
  200. dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/with_retry.json +42 -0
  201. dbextractors-1.0.0/tests/fixtures/oracle/index.json +395 -0
  202. dbextractors-1.0.0/tests/golden/conftest.py +86 -0
  203. dbextractors-1.0.0/tests/golden/test_cli.py +132 -0
  204. dbextractors-1.0.0/tests/golden/test_comparator.py +617 -0
  205. dbextractors-1.0.0/tests/golden/test_deviations.py +183 -0
  206. dbextractors-1.0.0/tests/golden/test_golden_credential_leaks.py +115 -0
  207. dbextractors-1.0.0/tests/golden/test_partitioning_golden.py +158 -0
  208. dbextractors-1.0.0/tests/golden/test_perturb.py +136 -0
  209. dbextractors-1.0.0/tests/golden/test_progress.py +122 -0
  210. dbextractors-1.0.0/tests/golden/test_safety.py +211 -0
  211. dbextractors-1.0.0/tests/golden/test_session.py +46 -0
  212. dbextractors-1.0.0/tests/golden/test_sqlgen_and_report.py +294 -0
  213. dbextractors-1.0.0/tests/hashing/test_hashing.py +278 -0
  214. dbextractors-1.0.0/tests/naming/test_naming.py +311 -0
  215. dbextractors-1.0.0/tests/naming/test_reserved_words.py +247 -0
  216. dbextractors-1.0.0/tests/oracle_store.py +319 -0
  217. dbextractors-1.0.0/tests/reference_oracle.py +515 -0
  218. dbextractors-1.0.0/tests/strategies/conftest.py +76 -0
  219. dbextractors-1.0.0/tests/strategies/test_full.py +629 -0
  220. dbextractors-1.0.0/tests/strategies/test_full_by_source.py +447 -0
  221. dbextractors-1.0.0/tests/strategies/test_hash_diff.py +598 -0
  222. dbextractors-1.0.0/tests/strategies/test_id_watermark.py +202 -0
  223. dbextractors-1.0.0/tests/strategies/test_incremental.py +556 -0
  224. dbextractors-1.0.0/tests/strategies/test_integer_precision_wiring.py +94 -0
  225. dbextractors-1.0.0/tests/strategies/test_legacy_target_all_strategies.py +162 -0
  226. dbextractors-1.0.0/tests/strategies/test_nchar_conversion_wiring.py +142 -0
  227. dbextractors-1.0.0/tests/strategies/test_parent_incremental.py +360 -0
  228. dbextractors-1.0.0/tests/strategies/test_parent_incremental_sql.py +191 -0
  229. dbextractors-1.0.0/tests/strategies/test_partitioning_db.py +310 -0
  230. dbextractors-1.0.0/tests/strategies/test_source_gains_column.py +246 -0
  231. dbextractors-1.0.0/tests/target/conftest.py +89 -0
  232. dbextractors-1.0.0/tests/target/test_fingerprint_contract.py +117 -0
  233. dbextractors-1.0.0/tests/target/test_target_pg.py +368 -0
  234. dbextractors-1.0.0/tests/target/test_target_pg_db.py +687 -0
  235. dbextractors-1.0.0/tests/target/test_target_timestamp_type.py +92 -0
  236. dbextractors-1.0.0/tests/target_pin.py +30 -0
  237. dbextractors-1.0.0/tests/test_e2e_run.py +147 -0
  238. dbextractors-1.0.0/tests/test_oracle_store.py +423 -0
  239. dbextractors-1.0.0/tests/test_reference_oracle.py +180 -0
  240. dbextractors-1.0.0/tests/test_skeleton.py +288 -0
@@ -0,0 +1,195 @@
1
+ # ARCHITECTURE.md — dbextractors
2
+
3
+ ## Why it is shaped like this
4
+
5
+ Measured by a machine comparison of the 15 existing extractor files (25,723 lines):
6
+
7
+ | | identical lines |
8
+ |---|---|
9
+ | MySQL ↔ MSSQL | 87 % |
10
+ | MySQL ↔ PostgreSQL | 78 % |
11
+ | MySQL ↔ Firebird | 55 % |
12
+ | common core of MySQL+MSSQL+PG | ~69 % of significant lines |
13
+
14
+ And of 95 functions, **45 are identical everywhere** they appear. Splitting the code
15
+ into *a core plus thin adapters* is therefore not a design idea but a description of
16
+ what that code already looks like.
17
+
18
+ **The target side is always PostgreSQL** — it is the single largest block of code and
19
+ it is 100 % shared.
20
+
21
+ ## Layout
22
+
23
+ ```
24
+ src/dbextractors/
25
+ entrypoint.py run(config, dialect, logger) -> pd.DataFrame
26
+
27
+ core/
28
+ config.py parsing and validation of TABLE / LOAD_SETTINGS / SOURCE_DB
29
+ retry.py with_retry, wait_for_port
30
+ tunnel.py SSH tunnel, keys, PDEATHSIG, connection_mode
31
+ coerce.py vectorised type conversion and sanitisation
32
+ hashing.py row_hash, choice of hashed columns
33
+ naming.py column name normalisation (reserved-word underscore prefixes)
34
+ status.py the returned DataFrame, logging, per-phase metrics
35
+ target_pg.py ← the largest module, 100 % shared
36
+
37
+ strategies/
38
+ base.py LoadStrategy ABC
39
+ full.py shadow table + TRUNCATE and INSERT … SELECT in one transaction
40
+ incremental.py window by updated_at / days_back
41
+ hash_diff.py hash comparison without a CDC log
42
+ id_watermark.py advance by an increasing PK
43
+ full_by_source.py partitioning of the target by source (MSSQL only so far)
44
+ parent_incremental.py window taken from a parent table (Firebird only so far)
45
+
46
+ dialects/
47
+ base.py SourceDialect ABC
48
+ mysql.py mssql.py postgres.py firebird.py
49
+ ```
50
+
51
+ The swap in `full.py` is deliberately **not** a `RENAME`. A view holds the OID of the
52
+ table it was built on, not its name, so after renaming the view stays bound to the old
53
+ relation and quietly serves data from the previous run. Loading into a shadow table and
54
+ then doing `TRUNCATE` + `INSERT … SELECT` in a single transaction keeps every dependent
55
+ view pointing at the same relation throughout, and an interrupted run leaves the target
56
+ untouched instead of truncated.
57
+
58
+ ## Data flow
59
+
60
+ ```
61
+ config (from the calling block)
62
+
63
+
64
+ core.config ── validation, filling in defaults
65
+
66
+
67
+ core.tunnel ── direct | ssh | auto → SQLAlchemy engine
68
+
69
+
70
+ dialects.<X> ── column introspection, type map, pagination SQL
71
+
72
+
73
+ strategies.<Y> ── drives the loop: what to read, in what batches, what to write
74
+ │ (calls the dialect to read, target_pg to write)
75
+
76
+ core.coerce ── vectorised sanitisation of the batch
77
+
78
+
79
+ core.target_pg ── COPY into the shadow/target table, upsert, indexes,
80
+ _deleted_in_source, _timestamp
81
+
82
+
83
+ core.status ── the returned DataFrame
84
+ ```
85
+
86
+ The key division of responsibility:
87
+
88
+ - **The dialect knows nothing about the target.** It can say what columns a table has,
89
+ how to read it in batches, and how its types map onto PG.
90
+ - **The strategy knows nothing about the SQL dialect.** It asks the dialect abstractly.
91
+ - **`target_pg` knows nothing about the source.** It is handed a DataFrame and column
92
+ metadata.
93
+
94
+ Because of that, a new source is a new file in `dialects/` and nothing else.
95
+
96
+ ## `SourceDialect` — the adapter interface
97
+
98
+ This is the only thing that has to be written for a new kind of database. A design, not
99
+ dogma — adjust it to what the comparison of the existing variants shows, but keep the
100
+ principle that the dialect does not know the target.
101
+
102
+ ```python
103
+ class SourceDialect(ABC):
104
+ name: str # 'mysql' | 'mssql' | 'postgres' | 'firebird'
105
+ default_port: int
106
+ type_map: dict[str, str] # source type -> PG type
107
+
108
+ def build_conn_str(self, params, host, port) -> str: ...
109
+ def probe(self, host, port, timeout) -> bool: ...
110
+
111
+ def introspect_columns(self, engine, database, schema, table) -> list[ColumnDef]: ...
112
+ def estimate_size(self, engine, table, where) -> tuple[float, int]: ...
113
+
114
+ def quote_ident(self, name: str) -> str: ...
115
+ def render_select(self, columns, table, where, order_by) -> str: ...
116
+
117
+ def source_ident(self, name: str) -> str: ...
118
+ # a name that did not come from introspection (configuration, a default in the
119
+ # code); Firebird upper-cases it, everywhere else this is quote_ident
120
+
121
+ def supports(self, feature: str) -> bool: ...
122
+ # 'keyset' | 'hash_diff' | 'partition_by_source' | 'parent_incremental'
123
+ ```
124
+
125
+ **One deviation from this sketch:** `page_offset` and `page_keyset` were originally part
126
+ of the interface, but they were **deleted as dead code** — every path reads through a
127
+ single cursor via `pd.read_sql(chunksize=)`, so `LIMIT/OFFSET` is never built anywhere in
128
+ the package. The cursor is also faster and has none of the quadratic overhead of a
129
+ growing `OFFSET`. The `keyset` capability in `supports()` stays: `id_watermark` and `full`
130
+ use it, and it is an independent property. See the CHANGELOG.
131
+
132
+ `source_ident` was added instead: generated SQL mixes names that came from introspection
133
+ (which have the shape the source uses) with names that came from the configuration or
134
+ from a default in the code (which do not). The second group has to go through here, or
135
+ Firebird will not find them.
136
+
137
+ Notes from the existing code:
138
+
139
+ - **MSSQL** has `TOP (n)` and `OFFSET … ROWS`, not `LIMIT`. It is the only one that
140
+ currently has multi-source and partitioning.
141
+ - **Firebird** is the most distant (55 % identical). It has no hash mode at all today,
142
+ introspection goes through the `RDB$` tables, and incremental loading is resolved
143
+ through a parent table. **Migrate it last.**
144
+ - **PostgreSQL as a source** additionally has `pagination_mode` and `id_watermark`.
145
+ - **MySQL** is 73 % of the volume — migrate it after the two smaller dialects, not first.
146
+
147
+ ## Strategies
148
+
149
+ | strategy | when | tables today |
150
+ |---|---|---|
151
+ | `full` | small tables, or no usable key | ~90 |
152
+ | `hash_diff` | no CDC log, but a stable PK | ~530 |
153
+ | `incremental` | there is a reliable modification-time column | ~27 |
154
+ | `id_watermark` | append-only, increasing PK | 4 |
155
+ | `full_by_source` | one target table fed from several sources | 16 |
156
+ | `parent_incremental` | window taken from a parent table (Firebird) | 24 |
157
+
158
+ `hash_diff` is both the dominant and the most expensive one — today it is
159
+ **O(source rows, not changed rows)**, ~15,000 rows/s. Optimising this single strategy
160
+ touches ~530 tables.
161
+
162
+ ## Distribution
163
+
164
+ A standalone package, installed with pip and pinned to a version:
165
+
166
+ ```
167
+ # requirements.txt in the consuming repository
168
+ dbextractors==1.0.0
169
+ ```
170
+
171
+ Extras pull in only the drivers a deployment actually needs, for example
172
+ `pip install "dbextractors[target,mysql]"`.
173
+
174
+ - The version is one line → upgrade and rollback happen per deployment, visibly in git.
175
+ - The package is tested outside Mage, in ordinary pytest.
176
+ - **The image build has to run in CI against a vendored wheel**, not `pip install` at
177
+ build time from a network source — otherwise an unreachable index takes the deploy
178
+ down.
179
+
180
+ ## Migration order
181
+
182
+ By size of impact, smallest first:
183
+
184
+ | # | group | tables | why here |
185
+ |---|---|---|---|
186
+ | 1 | variant B / PostgreSQL | 21 | the smallest real dialect; fixes a known hash bug on the way |
187
+ | 2 | variant C / MSSQL | 16 | adds partitioning and multi-source |
188
+ | 3 | variant A / PostgreSQL | 58 | proves it scales |
189
+ | 4 | variant A / MySQL | 254 | |
190
+ | 5 | variant C / MySQL | 237 | including folding the `_v2` fork back into options |
191
+ | 6 | variant B / Firebird | 80 | the most distant dialect, last |
192
+ | 7 | retiring the older generation | 3 | `*_loader`, `*_universal_extractor` |
193
+
194
+ MySQL is 73 % of the volume but is deliberately only fourth. If the golden test turns out
195
+ to have holes, better that it shows on 21 tables.
@@ -0,0 +1,377 @@
1
+ # Changelog
2
+
3
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ the versioning follows [semver](https://semver.org/).
5
+
6
+ Every release is tagged, and the tag is what a deployment pins in `requirements.txt`.
7
+ **Every change carries a note on what it breaks** — roughly 670 tables depend on
8
+ this package.
9
+
10
+ ## [1.0.0]
11
+
12
+ ### Added
13
+
14
+ - **`LOAD_SETTINGS.convert_nchar_to_varchar` — MSSQL `NVARCHAR` text that arrives
15
+ whole.** MSSQL is read over a `cp1250` connection that pymssql and FreeTDS
16
+ disagree about: the bytes are produced as latin-1 and decoded as cp1250, so an
17
+ `NVARCHAR`/`NCHAR`/`NTEXT` value is cut at the first character latin-1 cannot
18
+ express (`'příliš žluťoučký kůň'` → `'p'`) and one that latin-1 *can* express
19
+ comes back as a different character (`ø` → `ř`). Documented under *Known
20
+ limitations* below; the charset cannot simply be changed, because it is exactly
21
+ what makes the legacy single-byte columns of a CP1250-collated source read
22
+ correctly, and seven candidate values were measured with none right for both.
23
+
24
+ With the key on, the N-typed columns — and only those, chosen by their
25
+ introspected type — are wrapped in `CONVERT(VARCHAR(MAX), …)` in the generated
26
+ `SELECT`. The server then converts them through the column's own collation and
27
+ they arrive whole over the same connection; what CP1250 cannot hold is degraded
28
+ rather than truncating the value (Cyrillic and emoji become `?`, `ø` is folded
29
+ onto `o`). Legacy `VARCHAR`/`CHAR`/`TEXT` are never wrapped. Verified against a
30
+ live MSSQL in `tests/dialects/test_source_db.py`, on the `dbo.unicode_edge`
31
+ fixture, with every assertion having a counterpart for the key off.
32
+
33
+ It lives in `LOAD_SETTINGS` rather than in `SOURCE_DB` beside `charset` because
34
+ it is a property of one table's load, not of the connection — `SOURCE_DB` is
35
+ shared by all 16 pipelines against this source, and `SourceDbConfig` never
36
+ reaches the SELECT in the first place.
37
+
38
+ **What breaks: nothing.** The default is `False` and the generated SQL is then
39
+ byte-identical to before, pinned by a test. Enabling it for a table is a
40
+ deliberate act with a cost: the contents of those columns change, while
41
+ `row_hash` does not move with them (the digests were always computed by the
42
+ server from the raw `NVARCHAR`), so under `load_method: hash` existing rows are
43
+ not seen as changed — that table needs one `forced_full_load` to repair its
44
+ history. Not for a table whose primary key is itself N-typed; see
45
+ [Backward compatibility](docs/legacy-compat.md#mssql-nvarchar-text-arrives-truncated).
46
+
47
+ - **`LOAD_SETTINGS.incremental_parent_key_column` and
48
+ `incremental_parent_id_column` — the `parent_incremental` join is configurable.**
49
+ `ParentIncrementalStrategy` has always read both keys, but neither was part of
50
+ the contract: `entrypoint._settings_dict` builds `ctx.settings` with
51
+ `dataclasses.asdict`, so a key that is not a field of `LoadSettingsConfig` could
52
+ never arrive, the hard-coded `parent_id` / `id` always won, and `config.parse`
53
+ logged the key as unknown on top. The strategy's own error message said *"Set
54
+ incremental_parent_key_column."* — advice that could not be followed.
55
+
56
+ **What breaks: nothing.** The defaults are the two constants the strategy used
57
+ anyway, which are what the predecessor hard-codes, so a configuration that does
58
+ not name them loads exactly as it did. What changed is that naming them now
59
+ works — for a child table whose foreign key is not called `parent_id`, or a
60
+ parent not keyed by `id`.
61
+
62
+ - **`LOAD_SETTINGS.strict_integer_precision` — large integers fail loudly instead
63
+ of being silently rounded.** An integer column that also holds a `NULL` is
64
+ promoted to `float64` by `pd.read_sql` before this package sees the frame, and
65
+ `float64` has a 53-bit mantissa: measured end to end on all four dialects, a
66
+ source value of `9007199254740993` lands in the target as `9007199254740992`,
67
+ and `1234567890123456789` as `1234567890123456768`. The row count is right and
68
+ the run is green. The trigger is the `NULL`, not the magnitude.
69
+
70
+ The rounding itself is **not** fixed and must not be — the predecessor loses the
71
+ same bits in the same place, so repairing the read would change what lands in
72
+ the target relative to it, and because the hash is rendered out of the frame it
73
+ would recompute `row_hash` for every table with a nullable numeric column and
74
+ force a full reload of all of them. Pinned in
75
+ `tests/coerce/test_int64_precision.py`.
76
+
77
+ With the new key on, a `float64` column bound for an integer column in the
78
+ target that holds `|value| > 2**53` raises `coerce.IntegerPrecisionError`,
79
+ naming the column, an offending value and the key that produced the failure.
80
+ The check is vectorised (one numpy comparison per column) and sits in
81
+ `target_pg.prepare_export_df`, at the last point where such a column is still a
82
+ float. The boundary is inclusive — exactly 2**53 passes.
83
+
84
+ **What breaks: nothing.** The default is `False` and every pipeline that does
85
+ not name the key writes exactly what it wrote before, rounding included. Enabled
86
+ by default it would fail an unknown number of tables, so switching it on is a
87
+ per-pipeline decision.
88
+
89
+ - **A test stack for all four sources** (`docker/compose.yml`, `make db-up`).
90
+ PostgreSQL as the target plus MySQL, MSSQL and Firebird as sources, each
91
+ seeded with a table built around the types that behave neither like numbers
92
+ nor like text: MySQL's zero date and its `TIME` that legitimately exceeds 24
93
+ hours, MSSQL's three date/time families and `MONEY`, Firebird's negative
94
+ `NUMERIC` scale and space-padded `CHAR`.
95
+
96
+ Until now three of the four sources had **no live test at all** — only string
97
+ assertions over generated SQL, which cannot catch a type that maps correctly
98
+ and whose value still does not survive the trip. New markers `needs_mysql`,
99
+ `needs_mssql` and `needs_firebird`; without a DSN those tests skip, and CI
100
+ requires them to actually run.
101
+
102
+ Also `.env.example`, which the `.gitignore` had whitelisted for a long time
103
+ without the file ever existing.
104
+
105
+
106
+
107
+ - **`SOURCE_DB.ssh_host_key_checking` — verification of the SSH host key.**
108
+ Until now the tunnel was opened with `StrictHostKeyChecking=no` and
109
+ `UserKnownHostsFile=/dev/null`, that is **without verifying the remote end**.
110
+ Inside a private network that is defensible, but it is the one place in the
111
+ package where the connection can be spoofed — and the only one where the
112
+ behaviour could not even be turned off::
113
+
114
+ SOURCE_DB:
115
+ ssh_host_key_checking: off | accept-new | strict
116
+
117
+ `off` is the inherited behaviour, `accept-new` remembers the key on the first
118
+ connection and refuses it once it changes, `strict` requires the host to be in
119
+ `known_hosts` beforehand. An invalid value fails in `config.validate`, not later
120
+ at the tunnel.
121
+
122
+ **What breaks:** nothing. The default is `off`, i.e. today's behaviour — the
123
+ configuration contract is frozen, and changing the default would take down runs
124
+ against hosts whose key was never recorded anywhere. For new deployments
125
+ `accept-new` is the recommended value.
126
+
127
+ - **`src/dbextractors/core/secrets.py` — one credential redaction for the whole package.**
128
+ The password is interpolated into a string in seven places (four dialect
129
+ `build_conn_str` implementations and three paths to the target), and in the
130
+ implementations this package replaces only one of them was guarded — a single
131
+ helper on the path to the target, protecting a single message. Everywhere else
132
+ it was enough for the string to reach the text of an exception; typically
133
+ through `create_engine`, which prints an unusable URL in full, password
134
+ included, into the log.
135
+
136
+ `secrets.redact()` handles a SQLAlchemy URL and a libpq DSN alike, and through
137
+ `extra` also literal values that no pattern knows about. It **masks** the
138
+ password (`password=***`) rather than discarding the whole token, so a redacted
139
+ line still shows the shape of what failed.
140
+
141
+ `entrypoint` wraps `create_engine` failures through the redaction and re-raises
142
+ with `from None`. That last part matters more than it looks: chaining with
143
+ `from err` would leave the original exception — carrying the full URL, password
144
+ and all — attached as `__cause__`, and any handler that prints a whole traceback
145
+ would put it in the log anyway. `target_conn.describe_dsn` delegates to the same
146
+ redaction, so there is one implementation rather than two that can drift.
147
+
148
+ - **Every remaining path a credential could take out of the process now goes
149
+ through that redaction.** The audit that closed the security pass triggered each
150
+ candidate on purpose — a wrong password, an unreachable host, an invalid DSN, a
151
+ session statement the server rejects, a tunnel that cannot bind — and read what
152
+ actually came out. Three findings were real leaks and are fixed:
153
+
154
+ - **A private key pasted into `SOURCE_DB.ssh_pkey` reached the log verbatim.**
155
+ That key is contracted to be a *path*, but when it holds the key itself
156
+ `os.stat` fails with `ENAMETOOLONG` and `core/tunnel.py` printed the whole
157
+ value — at `ERROR` level, not gated on `DEBUG`. `ssh` then echoes its own `-i`
158
+ argument back on stderr, which the package splices into the `RuntimeError`
159
+ raised when the tunnel does not come up, and under `DEBUG` the argv is logged
160
+ as well. `secrets.redact()` now masks any PEM private-key block, in every
161
+ spelling `ssh` accepts and including one whose `-----END-----` never arrived.
162
+ Of everything this package masks it is the only secret a changed database
163
+ password does not retire.
164
+
165
+ - **`psycopg2` quotes back the token it choked on in a malformed DSN.** A target
166
+ password containing a space makes the DSN invalid *because of* the password,
167
+ and the fragment quoted back is a piece of it: `invalid dsn: missing "=" after
168
+ "…"`. `secrets.dsn_secrets()` extracts the password as written, whole and in
169
+ pieces, for `redact(extra=…)`; `entrypoint._target_connection` and
170
+ `golden.session.connect` both wrap the connect and re-raise redacted, with
171
+ `from None`.
172
+
173
+ - **`run()`'s per-database handler emitted an unredacted traceback** through
174
+ `logging.exception`, and put `str(err)` into the Mage log, into the `error`
175
+ column of the returned DataFrame and into `SourceExtractionError` — four exits
176
+ for one string. The traceback is now rendered in the handler so that it *can*
177
+ be redacted, and the redaction happens once, with the configured password in
178
+ `extra`.
179
+
180
+ Redaction was also added where nothing leaks today but the text is quoted
181
+ wholesale and comes from a third party: `core/reading.py`, `core/retry.py`, the
182
+ MSSQL and Firebird retry logs, `entrypoint._attach_session_sql`,
183
+ `status.error_status`, `hash_diff`'s fallback reason, `golden/compare.py`'s
184
+ `report.error` (which is written to the `--json` file on disk) and the
185
+ malformed-format-string branch of the logger adapter, the one place in the
186
+ package that `repr()`s log arguments it never inspected.
187
+
188
+ All four drivers were checked live: none of psycopg2, mysql-connector, pymssql
189
+ or fdb puts the connection string into a failed connection's message today.
190
+ That is four third-party packages' current behaviour rather than a guarantee, so
191
+ it is pinned by tests — a driver upgrade that changes it fails a test instead of
192
+ filling a production log.
193
+
194
+ **What breaks:** two exception *types* change on paths that previously raised a
195
+ raw driver error. A failure to connect to the target raises
196
+ `TargetConnectionError` instead of `psycopg2.OperationalError` /
197
+ `ProgrammingError` (both are caught by `run`'s `except Exception`, so a pipeline
198
+ sees no difference), and `golden.session.connect` raises `SessionError`, which
199
+ `dbx-golden` already catches — the CLI now prints one redacted line and exits 2
200
+ (`ERROR`, its documented code) instead of dumping a traceback and exiting 1. The
201
+ `🛑 [db] extraction failed` record is emitted with `logging.error` and a rendered
202
+ traceback rather than `logging.exception`, so the record no longer carries
203
+ `exc_info`; the text is the same, chained `__cause__` frames included.
204
+
205
+ - **The characterisation oracle runs without the legacy extractor sources.**
206
+ The characterisation tests used to need the predecessor's code; without it three
207
+ test modules were not collected at all and another 154 tests failed.
208
+ `tests/reference_oracle.py` now has two modes:
209
+
210
+ - `live` — the legacy sources are available and answers are extracted from their
211
+ AST as before. With `DBX_ORACLE_RECORD=1` every call is recorded along the way.
212
+ - `replay` — the legacy sources are absent and answers come from frozen fixtures
213
+ in `tests/fixtures/oracle/` (108 files, 1 359 calls, 904 kB).
214
+
215
+ The mode is detected from whether that source tree is present; `DBX_ORACLE_MODE`
216
+ forces it. Fixtures are recorded with `DBX_ORACLE_RECORD=1` where the
217
+ predecessor sources exist, and CI runs the replay as a separate step so they cannot go
218
+ stale.
219
+
220
+ The format is JSON, not pickle: fixtures can be read by eye, a diff shows what
221
+ changed, and loading one does not execute foreign code.
222
+
223
+ **What is not replayed:** functions with a side effect outside their arguments
224
+ (`ensure_private_key_permissions` changes file permissions) and functions that
225
+ take a function as an argument (`with_retry`). They are listed in
226
+ `reference_oracle.LIVE_ONLY` and their tests are skipped in `replay` mode —
227
+ 78 tests. It is an acknowledged gap, not a silent loss of coverage.
228
+
229
+ **What breaks:** nothing. Where the legacy sources are available the behaviour is
230
+ unchanged and the same 1171 tests pass.
231
+
232
+ - `scripts/golden_batch.py` carries its own `expand_env`, which it used to import
233
+ from a migration helper built on top of the legacy extractor sources. That
234
+ dependency pointed the wrong way: it made `golden_batch` — and with it
235
+ `tests/golden/test_perturb.py` — impossible to import without the predecessor's
236
+ code.
237
+
238
+ ### Fixed
239
+
240
+ - **Three pieces of documentation that described something the code does not do.**
241
+ None of them changes behaviour; all three were the kind of plausible, readable
242
+ falsehood that costs an hour of looking in the wrong place.
243
+
244
+ - `IncrementalStrategy` claimed to report a `stale_by_days` metric and to warn
245
+ when it is exceeded. The name appears nowhere else in the package. The claim
246
+ is removed rather than implemented, and the docstring now says why: detecting
247
+ the hole an outage leaves needs the interval between two successful runs, and
248
+ nothing records when a table last ran — every proxy the target can be asked
249
+ for (`MAX(_timestamp)`, `MAX(updated_at)`) cannot tell a quiet table from one
250
+ whose changes were missed, so it would warn on healthy tables for ever. The
251
+ hazard itself is still documented; only the false promise is gone. The metric
252
+ set is now pinned by a test, because this file had drifted twice.
253
+ - `docs/mage-loader-block.md` listed six of the ten columns of the returned
254
+ frame, omitting `load_method` and `error`. The table is corrected; "four extra
255
+ columns" was right and stays.
256
+ - `pagination_mode`, `keyset_pagination`, `conflict_columns` and `num_parallel`
257
+ are parsed and read by no strategy. They **stay** in the contract — a pipeline
258
+ that sets one has to keep starting — but each field in `core/config.py` now
259
+ says it is accepted and inert, and `docs/legacy-compat.md` gained a section
260
+ listing them with the reason. A key that silently does nothing is a trap for
261
+ whoever sets it expecting an effect.
262
+
263
+ - **MySQL: a source without an explicit `charset` could not connect.**
264
+ Configuration parsing deliberately does not fill a charset in — which
265
+ encoding a database expects is the dialect's knowledge — so `SOURCE_DB`
266
+ without one reached the dialect as an explicit `None`. `params.get("charset",
267
+ "utf8mb4")` then returned that `None` rather than the default, the URL became
268
+ `?charset=None`, and the driver refused it with *"Character set 'None'
269
+ unsupported"*.
270
+
271
+ Every MySQL pipeline that did not spell the charset out was affected. It went
272
+ unnoticed because no test had ever built a connection against a live server;
273
+ the very first one did. Firebird already used the `or` form and was never
274
+ affected.
275
+
276
+ **What breaks:** nothing — this only replaces a failure with a working
277
+ default of `utf8mb4`, which is what the predecessors used.
278
+
279
+ First public release. The package itself is older than this version number —
280
+ everything before it happened in a private repository and is summarised under
281
+ `[0.1.0]` below. 1.0.0 is where the configuration contract becomes a public
282
+ promise rather than an internal one.
283
+
284
+
285
+ ### Known limitations
286
+
287
+ - **MSSQL: `NVARCHAR`, `NCHAR` and `NTEXT` values are truncated at the first
288
+ character latin-1 cannot express.** `'příliš žluťoučký kůň'` arrives as
289
+ `'p'` — silently, with no error and no replacement character. Pure ASCII is
290
+ unaffected, which is why most columns look healthy; a character that *is* in
291
+ latin-1 arrives as a different character instead of being cut.
292
+
293
+ The cause is the connection charset `cp1250`
294
+ (`MSSQLDialect.connect_args`): with pymssql 2.3.13 the driver converts the
295
+ server's UCS-2 as latin-1 while the client decodes the result as cp1250.
296
+
297
+ **This is inherited, not new.** Both predecessor extractors open the
298
+ connection with the same charset, so the affected target columns have held
299
+ the truncated text since they were created. It remains the **default** on
300
+ purpose: no charset value reads both the N-types and the legacy single-byte
301
+ columns correctly, and `UTF-8` merely trades the truncation for mojibake in
302
+ every legacy `VARCHAR` of a CP1250-collated source. Changing it silently would
303
+ change the target's contents relative to the old side.
304
+
305
+ A table can now opt out with `LOAD_SETTINGS.convert_nchar_to_varchar` (see
306
+ *Added* above), which converts on the server instead of touching the charset.
307
+ It stays off by default, so this remains the behaviour of every table that does
308
+ not ask.
309
+
310
+ **What breaks:** nothing — this is a description of behaviour that has not
311
+ changed. The measurements behind the table, and what the opt-in costs, are in
312
+ [Backward compatibility](docs/legacy-compat.md#mssql-nvarchar-text-arrives-truncated).
313
+
314
+ ## [0.1.0] — pre-release history
315
+
316
+ Everything below this line happened before publication. The per-version notes from
317
+ that period were internal migration notes rather than a library changelog, so they
318
+ are summarised here instead of being released as public history.
319
+
320
+ The package replaces a set of hand-copied extractor blocks that had drifted apart
321
+ across several deployments while serving roughly 670 tables. Three constraints
322
+ drove the rewrite and still shape the code:
323
+
324
+ - **The configuration contract is frozen.** Hundreds of pipeline definitions keep
325
+ working untouched, including older shapes that predate the sectioned
326
+ configuration — see [Backward compatibility](docs/legacy-compat.md).
327
+ - **Target column names must not change.** The predecessor wrote through Mage's
328
+ PostgreSQL exporter, which prefixes an underscore to any name whose uppercase
329
+ form is one of 825 reserved words; a dbt layer is built on those names. The list
330
+ is replicated in the package and compared against the live `mage_ai` inside the
331
+ production image, so a Mage upgrade that touches it fails CI instead of quietly
332
+ renaming columns.
333
+ - **`_deleted_in_source`, `_timestamp` and `row_hash` are maintained by every
334
+ strategy**, not only by the ones that happened to support them before.
335
+
336
+ Capabilities arrived roughly in this order:
337
+
338
+ 1. **Configuration, retry, SSH tunnel and the conversion layer.** Configuration is
339
+ parsed into a typed dataclass and validated on input; the tunnel is a context
340
+ manager with `PR_SET_PDEATHSIG` and an explicit `connection_mode`. The
341
+ conversion layer is vectorised and 2.5x–3.1x faster than its predecessor;
342
+ throughput is per row *times* column, so table width dominates — 67 000 rows/s
343
+ at 8 columns, but only 8 600 at 48.
344
+ 2. **Target column naming**, replicated from the Mage exporter and verified against
345
+ a real target: 2 713 columns, 0 differences.
346
+ 3. **The write path** (`src/dbextractors/core/target_pg.py`) on bare psycopg2. `COPY … FROM STDIN`
347
+ is the only write path and reaches 260 000 rows/s; a full load goes into a
348
+ shadow table swapped in a single transaction, so views survive and an
349
+ interrupted run leaves the target untouched; deduplication runs through a
350
+ temporary table instead of a Python `set` holding every key.
351
+ 4. **Vectorised `row_hash`** — 8.2x to 9.0x faster than the per-row version and
352
+ bit-for-bit identical to it.
353
+ 5. **The six load strategies**: `full`, `incremental`, `hash_diff` (the dominant
354
+ one, serving ~530 of the ~670 tables, computing its diff in SQL rather than in
355
+ RAM), `id_watermark`, `parent_incremental` and `full_by_source`.
356
+ 6. **The golden test** (`dbextractors.golden`, CLI `dbx-golden`), which compares
357
+ two target tables on five levels — row counts, column names and order, types,
358
+ per-column checksums and per-row `row_hash` — and returns a verdict. No function
359
+ counted as finished until a golden test for it existed and passed.
360
+ 7. **Dialects**: MySQL and PostgreSQL first, then MSSQL and Firebird, each verified
361
+ against a live source. The largest verified comparison is 25 334 772 rows with
362
+ no unexplained differences.
363
+ 8. **Fixes that only running against live sources could find**: the target is
364
+ resolved from `io_config.yaml` rather than from environment variables, a
365
+ connection lost mid-read is retried and resumed from the last key that got
366
+ through, a source that has gained a column no longer fails the load, and
367
+ `_timestamp` is accepted as either `text` or `timestamp`.
368
+ 9. **Partitioning of the target table** through `LOAD_SETTINGS.partition_by`
369
+ (`list`, `range_day`, `range_month`, `range_year`), with partitions created from
370
+ the values the data actually contains.
371
+
372
+ Two behaviours changed on purpose relative to the predecessor. An unreachable
373
+ source **fails** the run instead of finishing green with zero rows, because a green
374
+ run with zero rows is indistinguishable from "nothing changed in the source" and
375
+ lets a table freeze unnoticed. A missing `primary_column` **fails** as well,
376
+ instead of silently degrading to a full load — the most expensive possible answer
377
+ to a typo in YAML.
@@ -0,0 +1,17 @@
1
+ # Code of Conduct
2
+
3
+ This project adopts the [Contributor Covenant, version
4
+ 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
5
+
6
+ The short version: be decent to people. Harassment, personal attacks and
7
+ demeaning comments are not welcome here, in issues, pull requests or anywhere
8
+ else the project happens. Disagreement about technical decisions is normal and
9
+ expected — this repository is full of choices that look wrong until you know
10
+ the production incident behind them, so ask before assuming, and explain rather
11
+ than dismiss when you are the one who knows.
12
+
13
+ Reports go to **robert@bisuperhero.cz** and are handled confidentially. The
14
+ maintainer is responsible for enforcement and may remove comments, close
15
+ threads, or ban an account.
16
+
17
+ The full text, including the enforcement guidelines, is at the link above.