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.
- dbextractors-1.0.0/ARCHITECTURE.md +195 -0
- dbextractors-1.0.0/CHANGELOG.md +377 -0
- dbextractors-1.0.0/CODE_OF_CONDUCT.md +17 -0
- dbextractors-1.0.0/CONTRIBUTING.md +99 -0
- dbextractors-1.0.0/LICENSE +202 -0
- dbextractors-1.0.0/MANIFEST.in +39 -0
- dbextractors-1.0.0/NOTICE +26 -0
- dbextractors-1.0.0/PKG-INFO +783 -0
- dbextractors-1.0.0/README.md +741 -0
- dbextractors-1.0.0/SECURITY.md +101 -0
- dbextractors-1.0.0/docs/data/mage_clean_name.py.txt +66 -0
- dbextractors-1.0.0/docs/data/mage_sql_reserved_words.json +832 -0
- dbextractors-1.0.0/docs/golden-test.md +247 -0
- dbextractors-1.0.0/docs/legacy-compat.md +285 -0
- dbextractors-1.0.0/docs/mage-loader-block.md +83 -0
- dbextractors-1.0.0/docs/partitioning-existing-table.md +104 -0
- dbextractors-1.0.0/pyproject.toml +150 -0
- dbextractors-1.0.0/setup.cfg +4 -0
- dbextractors-1.0.0/src/dbextractors/__init__.py +23 -0
- dbextractors-1.0.0/src/dbextractors/core/__init__.py +9 -0
- dbextractors-1.0.0/src/dbextractors/core/_reserved_words.py +846 -0
- dbextractors-1.0.0/src/dbextractors/core/coerce.py +1064 -0
- dbextractors-1.0.0/src/dbextractors/core/config.py +770 -0
- dbextractors-1.0.0/src/dbextractors/core/hashing.py +293 -0
- dbextractors-1.0.0/src/dbextractors/core/logging.py +102 -0
- dbextractors-1.0.0/src/dbextractors/core/naming.py +278 -0
- dbextractors-1.0.0/src/dbextractors/core/partitioning.py +510 -0
- dbextractors-1.0.0/src/dbextractors/core/reading.py +174 -0
- dbextractors-1.0.0/src/dbextractors/core/retry.py +108 -0
- dbextractors-1.0.0/src/dbextractors/core/secrets.py +165 -0
- dbextractors-1.0.0/src/dbextractors/core/status.py +179 -0
- dbextractors-1.0.0/src/dbextractors/core/strategies/__init__.py +20 -0
- dbextractors-1.0.0/src/dbextractors/core/strategies/base.py +497 -0
- dbextractors-1.0.0/src/dbextractors/core/strategies/full.py +699 -0
- dbextractors-1.0.0/src/dbextractors/core/strategies/full_by_source.py +574 -0
- dbextractors-1.0.0/src/dbextractors/core/strategies/hash_diff.py +591 -0
- dbextractors-1.0.0/src/dbextractors/core/strategies/id_watermark.py +240 -0
- dbextractors-1.0.0/src/dbextractors/core/strategies/incremental.py +567 -0
- dbextractors-1.0.0/src/dbextractors/core/strategies/parent_incremental.py +410 -0
- dbextractors-1.0.0/src/dbextractors/core/target_conn.py +188 -0
- dbextractors-1.0.0/src/dbextractors/core/target_pg.py +1948 -0
- dbextractors-1.0.0/src/dbextractors/core/tunnel.py +496 -0
- dbextractors-1.0.0/src/dbextractors/dialects/__init__.py +48 -0
- dbextractors-1.0.0/src/dbextractors/dialects/base.py +503 -0
- dbextractors-1.0.0/src/dbextractors/dialects/firebird.py +642 -0
- dbextractors-1.0.0/src/dbextractors/dialects/mssql.py +576 -0
- dbextractors-1.0.0/src/dbextractors/dialects/mysql.py +337 -0
- dbextractors-1.0.0/src/dbextractors/dialects/postgres.py +357 -0
- dbextractors-1.0.0/src/dbextractors/entrypoint.py +764 -0
- dbextractors-1.0.0/src/dbextractors/golden/__init__.py +37 -0
- dbextractors-1.0.0/src/dbextractors/golden/cli.py +266 -0
- dbextractors-1.0.0/src/dbextractors/golden/compare.py +757 -0
- dbextractors-1.0.0/src/dbextractors/golden/deviations.py +229 -0
- dbextractors-1.0.0/src/dbextractors/golden/introspect.py +133 -0
- dbextractors-1.0.0/src/dbextractors/golden/model.py +221 -0
- dbextractors-1.0.0/src/dbextractors/golden/progress.py +134 -0
- dbextractors-1.0.0/src/dbextractors/golden/report.py +275 -0
- dbextractors-1.0.0/src/dbextractors/golden/runners.py +247 -0
- dbextractors-1.0.0/src/dbextractors/golden/scratch.py +127 -0
- dbextractors-1.0.0/src/dbextractors/golden/session.py +249 -0
- dbextractors-1.0.0/src/dbextractors/golden/sqlgen.py +266 -0
- dbextractors-1.0.0/src/dbextractors/py.typed +0 -0
- dbextractors-1.0.0/src/dbextractors.egg-info/PKG-INFO +783 -0
- dbextractors-1.0.0/src/dbextractors.egg-info/SOURCES.txt +238 -0
- dbextractors-1.0.0/src/dbextractors.egg-info/dependency_links.txt +1 -0
- dbextractors-1.0.0/src/dbextractors.egg-info/entry_points.txt +2 -0
- dbextractors-1.0.0/src/dbextractors.egg-info/requires.txt +28 -0
- dbextractors-1.0.0/src/dbextractors.egg-info/top_level.txt +1 -0
- dbextractors-1.0.0/tests/coerce/test_characterization_coerce.py +628 -0
- dbextractors-1.0.0/tests/coerce/test_int64_precision.py +207 -0
- dbextractors-1.0.0/tests/coerce/test_strict_integer_precision.py +207 -0
- dbextractors-1.0.0/tests/conftest.py +63 -0
- dbextractors-1.0.0/tests/core/test_config.py +1009 -0
- dbextractors-1.0.0/tests/core/test_credential_leaks.py +634 -0
- dbextractors-1.0.0/tests/core/test_entrypoint.py +585 -0
- dbextractors-1.0.0/tests/core/test_logging.py +187 -0
- dbextractors-1.0.0/tests/core/test_partitioning.py +188 -0
- dbextractors-1.0.0/tests/core/test_reading.py +182 -0
- dbextractors-1.0.0/tests/core/test_required_columns.py +159 -0
- dbextractors-1.0.0/tests/core/test_retry.py +438 -0
- dbextractors-1.0.0/tests/core/test_secrets.py +206 -0
- dbextractors-1.0.0/tests/core/test_status.py +184 -0
- dbextractors-1.0.0/tests/core/test_target_conn.py +494 -0
- dbextractors-1.0.0/tests/core/test_tunnel.py +660 -0
- dbextractors-1.0.0/tests/dialects/conftest.py +92 -0
- dbextractors-1.0.0/tests/dialects/source_db.py +106 -0
- dbextractors-1.0.0/tests/dialects/test_firebird.py +732 -0
- dbextractors-1.0.0/tests/dialects/test_mssql.py +484 -0
- dbextractors-1.0.0/tests/dialects/test_mysql.py +331 -0
- dbextractors-1.0.0/tests/dialects/test_postgres.py +130 -0
- dbextractors-1.0.0/tests/dialects/test_postgres_db.py +216 -0
- dbextractors-1.0.0/tests/dialects/test_source_db.py +910 -0
- dbextractors-1.0.0/tests/fakes.py +398 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-my-ld/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-my-ld/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-my-ld/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-my-ld/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/_clean.json +30 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/_fmt_int_for_csv.json +1587 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/_is_pd_na.json +1477 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/_normalize_carriage_returns.json +321 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/_pk_to_str.json +141 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/add_hash_and_timestamp.json +2618 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/align_df_columns_to_db.json +692 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/create_column_mapping.json +1199 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/decode_bytes_columns.json +294 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/drop_mage_cols.json +123 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/ensure_private_key_permissions.json +81 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/find_free_port.json +11 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/fix_column_values.json +378 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/fix_invalid_dates.json +580 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/normalize_private_key_contents.json +126 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/prepare_export_df.json +312 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/replace_pdna_with_none.json +282 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/sanitize_integer_columns.json +419 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/to_int_or_na.json +1613 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/to_int_or_none.json +1621 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/to_jsonb.json +674 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/to_str_or_none.json +1491 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/update_stats.json +1066 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/wait_for_port.json +68 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-mysql/with_retry.json +210 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg/_pk_to_str.json +141 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg/fix_column_values.json +754 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg-ld/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg-ld/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg-ld/quote_ident.json +93 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg-ld/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/A-pg-ld/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-fbird/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-fbird/fix_column_values.json +378 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-fbird/update_stats.json +1066 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-fbird/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-fbird/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/_decode_value.json +78 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/_pk_to_str.json +141 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/fix_column_values.json +378 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/update_stats.json +1066 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mssql/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-my-uni/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-my-uni/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-my-uni/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-my-uni/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/_clean.json +678 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/_pk_to_str.json +141 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/fix_column_values.json +378 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/update_stats.json +1066 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-mysql/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/_pk_to_str.json +141 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/fix_column_values.json +754 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/to_bool.json +1603 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/to_date_str.json +1661 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/to_datetime_str.json +1661 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/to_jsonb.json +674 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/to_time_str.json +238 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg-ld/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg-ld/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg-ld/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/B-pg-ld/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/_pk_to_str.json +141 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/fix_column_values.json +378 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/is_truthy.json +350 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/update_stats.json +1066 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mssql/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-uni/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-uni/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-uni/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-uni/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/_pk_to_str.json +141 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/fix_column_values.json +378 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/update_stats.json +1066 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-my-v2/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/_pk_to_str.json +141 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/compute_hash_columns.json +531 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/create_column_mapping.json +249 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/fix_column_values.json +378 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/update_stats.json +1066 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/wait_for_port.json +24 -0
- dbextractors-1.0.0/tests/fixtures/oracle/C-mysql/with_retry.json +42 -0
- dbextractors-1.0.0/tests/fixtures/oracle/index.json +395 -0
- dbextractors-1.0.0/tests/golden/conftest.py +86 -0
- dbextractors-1.0.0/tests/golden/test_cli.py +132 -0
- dbextractors-1.0.0/tests/golden/test_comparator.py +617 -0
- dbextractors-1.0.0/tests/golden/test_deviations.py +183 -0
- dbextractors-1.0.0/tests/golden/test_golden_credential_leaks.py +115 -0
- dbextractors-1.0.0/tests/golden/test_partitioning_golden.py +158 -0
- dbextractors-1.0.0/tests/golden/test_perturb.py +136 -0
- dbextractors-1.0.0/tests/golden/test_progress.py +122 -0
- dbextractors-1.0.0/tests/golden/test_safety.py +211 -0
- dbextractors-1.0.0/tests/golden/test_session.py +46 -0
- dbextractors-1.0.0/tests/golden/test_sqlgen_and_report.py +294 -0
- dbextractors-1.0.0/tests/hashing/test_hashing.py +278 -0
- dbextractors-1.0.0/tests/naming/test_naming.py +311 -0
- dbextractors-1.0.0/tests/naming/test_reserved_words.py +247 -0
- dbextractors-1.0.0/tests/oracle_store.py +319 -0
- dbextractors-1.0.0/tests/reference_oracle.py +515 -0
- dbextractors-1.0.0/tests/strategies/conftest.py +76 -0
- dbextractors-1.0.0/tests/strategies/test_full.py +629 -0
- dbextractors-1.0.0/tests/strategies/test_full_by_source.py +447 -0
- dbextractors-1.0.0/tests/strategies/test_hash_diff.py +598 -0
- dbextractors-1.0.0/tests/strategies/test_id_watermark.py +202 -0
- dbextractors-1.0.0/tests/strategies/test_incremental.py +556 -0
- dbextractors-1.0.0/tests/strategies/test_integer_precision_wiring.py +94 -0
- dbextractors-1.0.0/tests/strategies/test_legacy_target_all_strategies.py +162 -0
- dbextractors-1.0.0/tests/strategies/test_nchar_conversion_wiring.py +142 -0
- dbextractors-1.0.0/tests/strategies/test_parent_incremental.py +360 -0
- dbextractors-1.0.0/tests/strategies/test_parent_incremental_sql.py +191 -0
- dbextractors-1.0.0/tests/strategies/test_partitioning_db.py +310 -0
- dbextractors-1.0.0/tests/strategies/test_source_gains_column.py +246 -0
- dbextractors-1.0.0/tests/target/conftest.py +89 -0
- dbextractors-1.0.0/tests/target/test_fingerprint_contract.py +117 -0
- dbextractors-1.0.0/tests/target/test_target_pg.py +368 -0
- dbextractors-1.0.0/tests/target/test_target_pg_db.py +687 -0
- dbextractors-1.0.0/tests/target/test_target_timestamp_type.py +92 -0
- dbextractors-1.0.0/tests/target_pin.py +30 -0
- dbextractors-1.0.0/tests/test_e2e_run.py +147 -0
- dbextractors-1.0.0/tests/test_oracle_store.py +423 -0
- dbextractors-1.0.0/tests/test_reference_oracle.py +180 -0
- 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.
|