Skip to content

Commit 4d2eaca

Browse files
aarthy-dkclaude
andcommitted
fix(profiling): skip sampling on views where the sample clause rejects it
SQL Server and PostgreSQL reject TABLESAMPLE on views ("can only be used with local tables" / "tables and materialized views"), so profiling a table group containing a view with sampling enabled errored those columns. Add a samples_views flavor capability (False for mssql/postgresql) and an object_type signal from those flavors' DDF, and skip sampling for views on those flavors — they are profiled in full. Also compute the per-run sampling params once and share them between column profiling and frequency analysis. Frequency analysis previously never sampled (the secondary query's TABLESAMPLE branch was unreachable); it now samples the same tables, with the view-skip applied. Sample-scale frequency counts are left unscaled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 89bb4ec commit 4d2eaca

20 files changed

Lines changed: 168 additions & 24 deletions

File tree

testgen/commands/queries/refresh_data_chars_query.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class RefreshDataCharsSQL:
3030
"general_type",
3131
"column_type",
3232
"db_data_type",
33+
"object_type",
3334
"approx_record_ct",
3435
"record_ct",
3536
)
@@ -157,6 +158,7 @@ def get_staging_data_chars(self, data_chars: list[ColumnChars], run_date: dateti
157158
column.general_type,
158159
column.column_type,
159160
column.db_data_type,
161+
column.object_type,
160162
column.approx_record_ct,
161163
column.record_ct,
162164
]

testgen/commands/run_profiling.py

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
write_to_app_db,
2020
)
2121
from testgen.common.database.column_chars import ColumnChars
22-
from testgen.common.database.database_service import ThreadedProgress
22+
from testgen.common.database.database_service import ThreadedProgress, get_flavor_service
2323
from testgen.common.job_context import job_context
2424
from testgen.common.mixpanel_service import MixpanelService
2525
from testgen.common.models import get_current_session, with_database_session
@@ -86,8 +86,9 @@ def run_profiling(
8686
if data_chars:
8787
sql_generator = ProfilingSQL(connection, table_group, profiling_run)
8888

89-
_run_column_profiling(sql_generator, data_chars)
90-
_run_frequency_analysis(sql_generator)
89+
sampling_params = _compute_sampling_params(sql_generator, data_chars)
90+
_run_column_profiling(sql_generator, data_chars, sampling_params)
91+
_run_frequency_analysis(sql_generator, sampling_params)
9192
_run_hygiene_issue_detection(sql_generator)
9293

9394
# if table_group.profile_do_pair_rules == "Y":
@@ -143,26 +144,40 @@ def _exclude_xde_columns(data_chars: list[ColumnChars], table_group_id: UUID) ->
143144
return filtered
144145

145146

146-
def _run_column_profiling(sql_generator: ProfilingSQL, data_chars: list[ColumnChars]) -> None:
147+
def _compute_sampling_params(
148+
sql_generator: ProfilingSQL, data_chars: list[ColumnChars]
149+
) -> dict[str, TableSampling]:
150+
table_group = sql_generator.table_group
151+
sampling_params: dict[str, TableSampling] = {}
152+
if not table_group.profile_use_sampling:
153+
return sampling_params
154+
155+
sampleable_types = get_flavor_service(sql_generator.flavor).sampleable_object_types
156+
for column in data_chars:
157+
if sampling_params.get(column.table_name):
158+
continue
159+
if sampleable_types is not None and column.object_type not in sampleable_types:
160+
continue
161+
result = calculate_sampling_params(
162+
table_name=column.table_name,
163+
record_count=column.record_ct,
164+
sample_percent_raw=table_group.profile_sample_percent,
165+
min_sample=table_group.profile_sample_min_count,
166+
)
167+
if result:
168+
sampling_params[column.table_name] = result
169+
return sampling_params
170+
171+
172+
def _run_column_profiling(
173+
sql_generator: ProfilingSQL, data_chars: list[ColumnChars], sampling_params: dict[str, TableSampling]
174+
) -> None:
147175
profiling_run = sql_generator.profiling_run
148176
profiling_run.set_progress("col_profiling", "Running")
149177
profiling_run.save()
150178
get_current_session().commit()
151179

152180
LOG.info(f"Running column profiling queries: {len(data_chars)}")
153-
table_group = sql_generator.table_group
154-
sampling_params: dict[str, TableSampling] = {}
155-
if table_group.profile_use_sampling:
156-
for column in data_chars:
157-
if not sampling_params.get(column.table_name):
158-
result = calculate_sampling_params(
159-
table_name=column.table_name,
160-
record_count=column.record_ct,
161-
sample_percent_raw=table_group.profile_sample_percent,
162-
min_sample=table_group.profile_sample_min_count,
163-
)
164-
if result:
165-
sampling_params[column.table_name] = result
166181

167182
def update_column_progress(progress: ThreadedProgress) -> None:
168183
profiling_run.set_progress(
@@ -218,7 +233,7 @@ def update_column_progress(progress: ThreadedProgress) -> None:
218233
)
219234

220235

221-
def _run_frequency_analysis(sql_generator: ProfilingSQL) -> None:
236+
def _run_frequency_analysis(sql_generator: ProfilingSQL, sampling_params: dict[str, TableSampling]) -> None:
222237
profiling_run = sql_generator.profiling_run
223238
profiling_run.set_progress("freq_analysis", "Running")
224239
profiling_run.save()
@@ -227,7 +242,7 @@ def _run_frequency_analysis(sql_generator: ProfilingSQL) -> None:
227242
error_data = None
228243
try:
229244
LOG.info("Selecting columns for frequency analysis")
230-
frequency_columns = fetch_dict_from_db(*sql_generator.get_frequency_analysis_columns())
245+
frequency_columns = [ColumnChars(**column) for column in fetch_dict_from_db(*sql_generator.get_frequency_analysis_columns())]
231246

232247
if frequency_columns:
233248
LOG.info(f"Running frequency analysis queries: {len(frequency_columns)}")
@@ -240,7 +255,10 @@ def update_frequency_progress(progress: ThreadedProgress) -> None:
240255
get_current_session().commit()
241256

242257
frequency_results, result_columns, error_data = fetch_from_db_threaded(
243-
[sql_generator.run_frequency_analysis(ColumnChars(**column)) for column in frequency_columns],
258+
[
259+
sql_generator.run_frequency_analysis(column, sampling_params.get(column.table_name))
260+
for column in frequency_columns
261+
],
244262
use_target_db=True,
245263
max_threads=sql_generator.connection.max_threads,
246264
progress_callback=update_frequency_progress,

testgen/common/database/column_chars.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
import dataclasses
2+
from enum import StrEnum
3+
4+
5+
class ObjectType(StrEnum):
6+
TABLE = "TABLE"
7+
VIEW = "VIEW"
8+
MATERIALIZED_VIEW = "MATERIALIZED_VIEW"
9+
EXTERNAL = "EXTERNAL"
10+
OTHER = "OTHER"
211

312

413
@dataclasses.dataclass
@@ -11,6 +20,7 @@ class ColumnChars:
1120
column_type: str | None = None
1221
db_data_type: str | None = None
1322
is_decimal: bool = False
23+
object_type: str | None = None # ObjectType values
1424
approx_record_ct: int | None = None
1525
# This should not default to 0 since we don't always retrieve actual row counts
1626
# UI relies on the null value to know that the approx_record_ct should be displayed instead

testgen/common/database/flavor/flavor_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from sqlalchemy import create_engine as sqlalchemy_create_engine
77
from sqlalchemy.engine.base import Engine
88

9-
from testgen.common.database.column_chars import ColumnChars
9+
from testgen.common.database.column_chars import ColumnChars, ObjectType
1010
from testgen.common.encrypt import DecryptText
1111

1212
SQLFlavor = Literal["redshift", "redshift_spectrum", "snowflake", "mssql", "postgresql", "databricks", "bigquery", "oracle", "sap_hana", "salesforce_data360"]
@@ -116,6 +116,7 @@ def row_limit_clauses(self, n: int) -> tuple[str, str]:
116116

117117
qualifies_table_refs_with_schema = True
118118
metadata_via_api = False
119+
sampleable_object_types: frozenset[ObjectType] | None = None
119120

120121
def get_schema_columns(self, _params: ResolvedConnectionParams, _schema: str) -> list[ColumnChars] | None:
121122
"""Return column metadata without querying information_schema.

testgen/common/database/flavor/mssql_flavor_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from sqlalchemy.engine import URL
22

33
from testgen import settings
4+
from testgen.common.database.column_chars import ObjectType
45
from testgen.common.database.flavor.flavor_service import FlavorService, ResolvedConnectionParams
56

67

@@ -10,6 +11,8 @@ class MssqlFlavorService(FlavorService):
1011
escaped_underscore = "[_]"
1112
row_limiting_clause = "top"
1213
url_scheme = "mssql+pyodbc"
14+
# TABLESAMPLE applies only to tables and materialized views
15+
sampleable_object_types = frozenset({ObjectType.TABLE, ObjectType.MATERIALIZED_VIEW})
1316

1417
def get_connection_string_from_fields(self, params: ResolvedConnectionParams) -> str:
1518
connection_url = URL.create(

testgen/common/database/flavor/postgresql_flavor_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from urllib.parse import quote_plus
22

3+
from testgen.common.database.column_chars import ObjectType
34
from testgen.common.database.flavor.flavor_service import ResolvedConnectionParams
45
from testgen.common.database.flavor.redshift_flavor_service import RedshiftFlavorService
56

@@ -8,6 +9,8 @@ class PostgresqlFlavorService(RedshiftFlavorService):
89

910
escaped_underscore = "\\_"
1011
url_scheme = "postgresql"
12+
# TABLESAMPLE applies only to tables and materialized views
13+
sampleable_object_types = frozenset({ObjectType.TABLE, ObjectType.MATERIALIZED_VIEW})
1114

1215
def get_connection_string_from_fields(self, params: ResolvedConnectionParams) -> str:
1316
if params.host.startswith("/"):

testgen/common/models/data_table.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class TableProfilingOverview:
4242
table_groups_id: UUID
4343
schema_name: str | None
4444
table_name: str
45+
object_type: str | None
4546
record_ct: int | None
4647
column_ct: int | None
4748
dq_score_profiling: float | None
@@ -61,6 +62,7 @@ class DataTable(Entity):
6162
table_groups_id: UUID = Column(postgresql.UUID(as_uuid=True), ForeignKey("table_groups.id"))
6263
schema_name: str | None = Column(String)
6364
table_name: str = Column(String)
65+
object_type: str | None = Column(String)
6466
column_ct: int | None = Column(BigInteger)
6567
record_ct: int | None = Column(BigInteger)
6668
approx_record_ct: int | None = Column(BigInteger)
@@ -110,6 +112,7 @@ def get_profiling_overview(
110112
cls.table_groups_id,
111113
cls.schema_name,
112114
cls.table_name,
115+
cls.object_type,
113116
cls.record_ct,
114117
cls.column_ct,
115118
cls.dq_score_profiling,

testgen/template/data_chars/data_chars_update.sql

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ WITH new_chars AS (
88
schema_name,
99
table_name,
1010
run_date,
11+
MAX(object_type) AS object_type,
1112
MAX(approx_record_ct) AS approx_record_ct,
1213
MAX(record_ct) AS record_ct,
1314
COUNT(*) AS column_ct
@@ -20,7 +21,8 @@ WITH new_chars AS (
2021
),
2122
updated_records AS (
2223
UPDATE data_table_chars
23-
SET approx_record_ct = n.approx_record_ct,
24+
SET object_type = n.object_type,
25+
approx_record_ct = n.approx_record_ct,
2426
record_ct = n.record_ct,
2527
column_ct = n.column_ct,
2628
last_refresh_date = n.run_date,
@@ -55,6 +57,7 @@ WITH new_chars AS (
5557
schema_name,
5658
table_name,
5759
run_date,
60+
MAX(object_type) AS object_type,
5861
MAX(approx_record_ct) AS approx_record_ct,
5962
MAX(record_ct) AS record_ct,
6063
COUNT(*) AS column_ct
@@ -72,6 +75,7 @@ inserted_records AS (
7275
table_name,
7376
add_date,
7477
last_refresh_date,
78+
object_type,
7579
approx_record_ct,
7680
record_ct,
7781
column_ct
@@ -81,6 +85,7 @@ inserted_records AS (
8185
n.table_name,
8286
n.run_date,
8387
n.run_date,
88+
n.object_type,
8489
n.approx_record_ct,
8590
n.record_ct,
8691
n.column_ct

testgen/template/dbsetup/030_initialize_new_schema_structure.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ CREATE TABLE stg_data_chars_updates (
3535
general_type VARCHAR(1),
3636
column_type VARCHAR(50),
3737
db_data_type VARCHAR(50),
38+
object_type VARCHAR(20),
3839
approx_record_ct BIGINT,
3940
record_ct BIGINT
4041
);
@@ -419,6 +420,7 @@ CREATE TABLE data_table_chars (
419420
table_groups_id UUID,
420421
schema_name VARCHAR(50),
421422
table_name VARCHAR(120),
423+
object_type VARCHAR(20),
422424
functional_table_type VARCHAR(50),
423425
description VARCHAR(1000),
424426
critical_data_element BOOLEAN,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
SET SEARCH_PATH TO {SCHEMA_NAME};
2+
3+
ALTER TABLE data_table_chars
4+
ADD COLUMN IF NOT EXISTS object_type VARCHAR(20);
5+
6+
ALTER TABLE stg_data_chars_updates
7+
ADD COLUMN IF NOT EXISTS object_type VARCHAR(20);

0 commit comments

Comments
 (0)