Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 45 additions & 14 deletions superset/commands/streaming_export/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,12 @@
import logging
import time
from abc import abstractmethod
from contextlib import contextmanager
from contextlib import closing, contextmanager
from decimal import Decimal
from numbers import Real
from typing import Any, Callable, Generator

from flask import current_app as app, g, has_app_context
from sqlalchemy import text

from superset import db
from superset.commands.base import BaseCommand
Expand Down Expand Up @@ -219,19 +218,44 @@ def _execute_query_and_stream(
delimiter = csv_export_config.get("sep", ",")
decimal_separator = csv_export_config.get("decimal", ".")

# Apply SQL mutations (e.g. SQL_QUERY_MUTATOR config hook) before
# execution. All non-streaming paths go through this — the streaming
# path was originally skipping it, which left trailing semicolons
# unstripped for engines like Trino that reject them.
sql = database.mutate_sql_based_on_config(sql)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The SQL mutator receives the original database object before it is merged into the active session. Streaming generators run after the request session may have ended, so a configured SQL_QUERY_MUTATOR that reads a lazy or session-bound database attribute can raise DetachedInstanceError. Merge the database first and invoke the mutator with the merged instance. [stale reference]

Severity Level: Major ⚠️
- ❌ Configured database-aware SQL mutators can fail during exports.
- ⚠️ Streaming response timing exposes detached ORM state.
- ⚠️ Affected SQL mutation and audit customization paths.
Steps of Reproduction ✅
1. Start a chart or SQL Lab streaming export through superset/charts/data/api.py:756-765
or superset/sqllab/api.py:417-435. BaseStreamingCSVExportCommand.run() captures the
database and returns a generator at superset/commands/streaming_export/base.py:129-145, so
query execution occurs during response streaming rather than during command construction.

2. Configure SQL_QUERY_MUTATOR with a function that uses the supplied database keyword
argument, which is explicitly passed by Database.mutate_sql_based_on_config() at
superset/models/core.py:787-805.

3. When the generator reaches superset/commands/streaming_export/base.py:225, it invokes
the mutator on the original database object before entering db.session(future=True) and
before session.merge(database) at base.py:227-230.

4. Because the generator runs after the original request work may have released or
detached its ORM session, a mutator that reads a deferred or session-bound Database
attribute can raise DetachedInstanceError. The merged instance is available at base.py:229
and should be used for mutation before opening the connection.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/commands/streaming_export/base.py
**Line:** 225:225
**Comment:**
	*Stale Reference: The SQL mutator receives the original database object before it is merged into the active session. Streaming generators run after the request session may have ended, so a configured `SQL_QUERY_MUTATOR` that reads a lazy or session-bound database attribute can raise `DetachedInstanceError`. Merge the database first and invoke the mutator with the merged instance.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


with db.session(future=True) as session:
# Merge database to prevent DetachedInstanceError
merged_database = session.merge(database)

with merged_database.get_sqla_engine(
catalog=catalog, schema=schema
) as engine:
with engine.connect() as connection:
result_proxy = connection.execution_options(
stream_results=True
).execute(text(sql))

columns = list(result_proxy.keys())
# Use get_raw_connection() instead of get_sqla_engine() directly.
# This is critical for:
# 1. User impersonation — get_raw_connection() goes through the
# ENGINE_CONTEXT_MANAGER which applies impersonate_user settings
# (e.g. X-Trino-User header). Without this, all streaming CSV
# exports run as the service principal, breaking audit trails
# and potentially bypassing per-user authorization (Ranger, OPA,
# RLS views).
# 2. SSH tunnels — get_raw_connection() sets up SSH tunnels if
# configured on the database.
# 3. OAuth2 — get_raw_connection() wraps execution in
# check_for_oauth2() context.
with closing(
merged_database.get_raw_connection(catalog=catalog, schema=schema)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_raw_connection is a @contextmanager (models/core.py), so it's meant to be entered directly: with database.get_raw_connection(...) as conn. wrapping the call in closing() means __enter__ never runs, so the engine setup inside it (the ENGINE_CONTEXT_MANAGER impersonation this is adding, plus oauth2 and ssh tunnels) gets skipped, and conn ends up being the _GeneratorContextManager instead of the dbapi connection. conn.cursor() then raises AttributeError, which the generator catches and turns into __STREAM_ERROR__, so the export would still fail and the impersonation wouldn't take effect.

every other call site enters it directly (e.g. sql/execution/executor.py, models/core.py). probably just:

with merged_database.get_raw_connection(
    catalog=catalog, schema=schema
) as conn:
    cursor = conn.cursor()

get_raw_connection already closes the connection internally, so the outer closing() isn't needed.

) as conn:
Comment on lines +243 to +245

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: get_raw_connection() is used with closing(), which only calls close() and does not enter or exit a context manager. Since this API provides the connection through a context manager to apply lifecycle handling such as OAuth2 cleanup, SSH tunnel management, and impersonation context, conn becomes the context-manager object rather than the raw DBAPI connection; conn.cursor() therefore fails or bypasses the required setup. Use the connection as a context manager directly and keep cursor cleanup inside that context. [resource leak]

Severity Level: Critical 🚨
- ❌ Streaming CSV exports fail before query execution.
- ❌ User impersonation and OAuth2 setup are bypassed.
- ❌ SSH tunnel lifecycle handling is not entered or exited.
- ⚠️ Export consumers receive stream errors instead of CSV output.
Steps of Reproduction ✅
1. Start a streaming CSV export through the streaming export command; `run()` at
`superset/commands/streaming_export/base.py:295` returns a generator whose consumption
executes `_execute_query_and_stream()`.

2. Consume the returned generator so execution reaches
`superset/commands/streaming_export/base.py:243-245`, where
`merged_database.get_raw_connection(catalog=catalog, schema=schema)` is wrapped with
`contextlib.closing` instead of being entered with a `with` statement.

3. `get_raw_connection()` provides a context-managed connection so its `__enter__` logic
establishes the raw connection and its impersonation, OAuth2, and SSH-tunnel contexts;
`closing` does not invoke that context manager's `__enter__`.

4. The assigned `conn` is therefore the context-manager wrapper rather than the DBAPI
connection, and `conn.cursor()` at `superset/commands/streaming_export/base.py:246` fails
before `cursor.execute(sql)` at line 248. The export generator emits a stream failure
instead of CSV data; cleanup is also not performed through the connection context.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/commands/streaming_export/base.py
**Line:** 243:245
**Comment:**
	*Resource Leak: `get_raw_connection()` is used with `closing()`, which only calls `close()` and does not enter or exit a context manager. Since this API provides the connection through a context manager to apply lifecycle handling such as OAuth2 cleanup, SSH tunnel management, and impersonation context, `conn` becomes the context-manager object rather than the raw DBAPI connection; `conn.cursor()` therefore fails or bypasses the required setup. Use the connection as a context manager directly and keep cursor cleanup inside that context.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

cursor = conn.cursor()
Comment on lines +243 to +246

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth confirming the streaming behaviour on this path: the old code used execution_options(stream_results=True), which for psycopg2/postgres sets up a server-side cursor. a raw conn.cursor() on psycopg2 buffers the whole result set client-side, so a large postgres export could pull everything into memory here. for trino the dbapi cursor fetches incrementally so it's fine, but since this is the streaming path it'd be good to keep big postgres exports bounded (server-side cursor where supported, or just call out the tradeoff).

# Set cursor.arraysize to control the batch size for fetchmany().
# This ensures DBAPI drivers (Trino, PostgreSQL, etc.) fetch
# rows in manageable chunks instead of buffering the entire
# result set client-side.
cursor.arraysize = limit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: limit is None for chart exports and unlimited SQL Lab exports, but DBAPI cursor.arraysize requires an integer. This assignment can raise a driver-specific type error for every unlimited streaming export, and it is unnecessary because _process_rows() already passes self._chunk_size explicitly to fetchmany(). [type error]

Severity Level: Critical 🚨
- ❌ Unlimited chart CSV exports can fail during cursor setup.
- ❌ Unlimited SQL Lab exports can fail during cursor setup.
- ⚠️ Driver behavior becomes database-dependent.
Steps of Reproduction ✅
1. Request a chart streaming CSV export through superset/charts/data/api.py:756-765;
StreamingCSVExportCommand._get_row_limit() explicitly returns None for every chart export
at superset/commands/chart/data/streaming_export_command.py:76-83.

2. After entering the raw connection context, execution reaches
BaseStreamingCSVExportCommand._execute_query_and_stream() at
superset/commands/streaming_export/base.py:194.

3. The code assigns None to cursor.arraysize at
superset/commands/streaming_export/base.py:251. DBAPI cursor arraysize is an integer
fetch-size attribute, and drivers may reject None with a TypeError or equivalent driver
error.

4. The subsequent _process_rows() call at base.py:281-283 already invokes
fetchmany(self._chunk_size), so arraysize is not needed to implement the configured batch
size. The export therefore fails for unlimited chart exports before rows are streamed; SQL
Lab exports with select_sql set also return None from
superset/commands/sql_lab/streaming_export_command.py:119-127.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/commands/streaming_export/base.py
**Line:** 251:251
**Comment:**
	*Type Error: `limit` is `None` for chart exports and unlimited SQL Lab exports, but DBAPI `cursor.arraysize` requires an integer. This assignment can raise a driver-specific type error for every unlimited streaming export, and it is unnecessary because `_process_rows()` already passes `self._chunk_size` explicitly to `fetchmany()`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two issues here: limit is the export row limit and can be None for unlimited, and cursor.arraysize = None isn't valid for dbapi drivers. also _process_rows calls fetchmany(self._chunk_size) with an explicit size, which overrides arraysize, so this line doesn't actually change the fetch batch size. if the intent is to control the batch, self._chunk_size would be the value, otherwise it can probably be dropped.

try:
cursor.execute(sql)
columns = (
[desc[0] for desc in cursor.description]
if cursor.description
else []
)

# Use StringIO with csv.writer for proper escaping
# Apply delimiter from CSV_EXPORT config
Expand All @@ -247,10 +271,15 @@ def _execute_query_and_stream(
total_bytes += header_bytes
yield header_data

# Process rows and yield chunks
# Process rows and yield chunks — cursor supports the
# same fetchmany() interface that _process_rows expects.
row_count = 0
for data_chunk, rows_processed, chunk_bytes in self._process_rows(
result_proxy, csv_writer, buffer, limit, decimal_separator
for (
data_chunk,
rows_processed,
chunk_bytes,
) in self._process_rows(
cursor, csv_writer, buffer, limit, decimal_separator
):
total_bytes += chunk_bytes
row_count = rows_processed
Expand All @@ -265,6 +294,8 @@ def _execute_query_and_stream(
total_mb,
total_time,
)
finally:
cursor.close()

def run(self) -> Callable[[], Generator[str, None, None]]:
"""
Expand Down
195 changes: 115 additions & 80 deletions tests/unit_tests/commands/chart/streaming_export_command_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ def test_csv_generation_with_small_dataset(mocker: MockerFixture) -> None:
"""Test CSV generation with a small dataset."""
mock_db, query_context, datasource = _setup_chart_mocks(mocker)

mock_result_proxy = mocker.MagicMock()
mock_result_proxy.keys.return_value = ["col1", "col2", "col3"]
mock_result_proxy.fetchmany.side_effect = [
mock_cursor = mocker.MagicMock()
mock_cursor.description = [("col1",), ("col2",), ("col3",)]
mock_cursor.fetchmany.side_effect = [
[
("row1_val1", "row1_val2", "row1_val3"),
("row2_val1", "row2_val2", "row2_val3"),
Expand All @@ -103,17 +103,11 @@ def test_csv_generation_with_small_dataset(mocker: MockerFixture) -> None:
[],
]

mock_connection = mocker.MagicMock()
mock_connection.execution_options.return_value.execute.return_value = (
mock_result_proxy
)
mock_connection.__enter__.return_value = mock_connection
mock_connection.__exit__.return_value = None
mock_conn = mocker.MagicMock()
mock_conn.cursor.return_value = mock_cursor

mock_engine = mocker.MagicMock()
mock_engine.connect.return_value = mock_connection
datasource.database.get_sqla_engine.return_value.__enter__.return_value = (
mock_engine
datasource.database.get_raw_connection.return_value = (
mock_conn
)
Comment on lines +109 to 111

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The test configures get_raw_connection() to return a raw MagicMock, but the real API returns a context manager that must be entered with with. This causes the tests to exercise an artificial contract and allows the production closing(get_raw_connection(...)) regression to pass unnoticed. Configure the mock as an entered context manager and assert that the context is entered and exited. [api mismatch]

Severity Level: Critical 🚨
- ❌ Unit tests miss production connection-entry failures.
- ⚠️ Streaming export regressions can pass CI undetected.
- ⚠️ Connection cleanup behavior is not verified.
Steps of Reproduction ✅
1. Run the chart streaming unit tests, whose common setup patches the database at
`tests/unit_tests/commands/chart/streaming_export_command_test.py:27-48`.

2. Each test assigns a raw `mock_conn` directly as `get_raw_connection.return_value`, for
example at `tests/unit_tests/commands/chart/streaming_export_command_test.py:106-111`,
rather than configuring `return_value.__enter__.return_value`.

3. Production `Database.get_raw_connection()` is a context manager at
`superset/models/core.py:675-692`, and the production code is supposed to enter it with
`with ... as conn`.

4. Consequently, these tests exercise a different API contract: they allow `conn.cursor()`
directly and cannot detect the current `closing(get_raw_connection(...))` failure at
`superset/commands/streaming_export/base.py:243-247`. Configure the entered connection
mock and assert context entry and exit.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** tests/unit_tests/commands/chart/streaming_export_command_test.py
**Line:** 109:111
**Comment:**
	*Api Mismatch: The test configures `get_raw_connection()` to return a raw `MagicMock`, but the real API returns a context manager that must be entered with `with`. This causes the tests to exercise an artificial contract and allows the production `closing(get_raw_connection(...))` regression to pass unnoticed. Configure the mock as an entered context manager and assert that the context is entered and exited.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


command = StreamingCSVExportCommand(query_context, chunk_size=2)
Expand All @@ -136,22 +130,18 @@ def test_csv_generation_with_special_characters(mocker: MockerFixture) -> None:
"""Test CSV generation properly escapes special characters."""
mock_db, query_context, datasource = _setup_chart_mocks(mocker)

mock_result = mocker.MagicMock()
mock_result.keys.return_value = ["name", "description"]
mock_result.fetchmany.side_effect = [
mock_cursor = mocker.MagicMock()
mock_cursor.description = [("name",), ("description",)]
mock_cursor.fetchmany.side_effect = [
[("John, Jr.", 'Quote"Test'), ("Line\nBreak", "Comma,Value")],
[],
]

mock_connection = mocker.MagicMock()
mock_connection.execution_options.return_value.execute.return_value = mock_result
mock_connection.__enter__.return_value = mock_connection
mock_connection.__exit__.return_value = None
mock_conn = mocker.MagicMock()
mock_conn.cursor.return_value = mock_cursor

mock_engine = mocker.MagicMock()
mock_engine.connect.return_value = mock_connection
datasource.database.get_sqla_engine.return_value.__enter__.return_value = (
mock_engine
datasource.database.get_raw_connection.return_value = (
mock_conn
)

command = StreamingCSVExportCommand(query_context, chunk_size=10)
Expand All @@ -169,22 +159,18 @@ def test_streaming_with_null_values(mocker: MockerFixture) -> None:
"""Test CSV generation handles NULL values correctly."""
mock_db, query_context, datasource = _setup_chart_mocks(mocker)

mock_result = mocker.MagicMock()
mock_result.keys.return_value = ["col1", "col2", "col3"]
mock_result.fetchmany.side_effect = [
mock_cursor = mocker.MagicMock()
mock_cursor.description = [("col1",), ("col2",), ("col3",)]
mock_cursor.fetchmany.side_effect = [
[("value1", None, "value3"), (None, "value2", None)],
[],
]

mock_connection = mocker.MagicMock()
mock_connection.execution_options.return_value.execute.return_value = mock_result
mock_connection.__enter__.return_value = mock_connection
mock_connection.__exit__.return_value = None
mock_conn = mocker.MagicMock()
mock_conn.cursor.return_value = mock_cursor

mock_engine = mocker.MagicMock()
mock_engine.connect.return_value = mock_connection
datasource.database.get_sqla_engine.return_value.__enter__.return_value = (
mock_engine
datasource.database.get_raw_connection.return_value = (
mock_conn
)

command = StreamingCSVExportCommand(query_context, chunk_size=10)
Expand All @@ -199,12 +185,12 @@ def test_streaming_with_null_values(mocker: MockerFixture) -> None:


def test_streaming_execution_options_enabled(mocker: MockerFixture) -> None:
"""Test that streaming execution options are enabled."""
"""Test that get_raw_connection is used for streaming (not get_sqla_engine)."""
Comment on lines 187 to +188
mock_db, query_context, datasource = _setup_chart_mocks(mocker)

mock_result_proxy = mocker.MagicMock()
mock_result_proxy.keys.return_value = ["col1", "col2", "col3"]
mock_result_proxy.fetchmany.side_effect = [
mock_cursor = mocker.MagicMock()
mock_cursor.description = [("col1",), ("col2",), ("col3",)]
mock_cursor.fetchmany.side_effect = [
[
("row1_val1", "row1_val2", "row1_val3"),
("row2_val1", "row2_val2", "row2_val3"),
Expand All @@ -213,44 +199,37 @@ def test_streaming_execution_options_enabled(mocker: MockerFixture) -> None:
[],
]

mock_connection = mocker.MagicMock()
mock_execution_options = mocker.MagicMock()
mock_connection.execution_options.return_value = mock_execution_options
mock_execution_options.execute.return_value = mock_result_proxy
mock_connection.__enter__.return_value = mock_connection
mock_connection.__exit__.return_value = None

mock_engine = mocker.MagicMock()
mock_engine.connect.return_value = mock_connection
datasource.database.get_sqla_engine.return_value.__enter__.return_value = (
mock_engine
mock_conn = mocker.MagicMock()
mock_conn.cursor.return_value = mock_cursor

datasource.database.get_raw_connection.return_value = (
mock_conn
)

command = StreamingCSVExportCommand(query_context)
csv_generator_callable = command.run()
generator = csv_generator_callable()
list(generator)

mock_connection.execution_options.assert_called_once_with(stream_results=True)
# Verify get_raw_connection is used (not get_sqla_engine)
datasource.database.get_raw_connection.assert_called_once_with(
catalog=None, schema=None
)


def test_empty_result_set(mocker: MockerFixture) -> None:
"""Test CSV generation with empty result set."""
mock_db, query_context, datasource = _setup_chart_mocks(mocker)

mock_result = mocker.MagicMock()
mock_result.keys.return_value = ["col1", "col2"]
mock_result.fetchmany.side_effect = [[]]
mock_cursor = mocker.MagicMock()
mock_cursor.description = [("col1",), ("col2",)]
mock_cursor.fetchmany.side_effect = [[]]

mock_connection = mocker.MagicMock()
mock_connection.execution_options.return_value.execute.return_value = mock_result
mock_connection.__enter__.return_value = mock_connection
mock_connection.__exit__.return_value = None
mock_conn = mocker.MagicMock()
mock_conn.cursor.return_value = mock_cursor

mock_engine = mocker.MagicMock()
mock_engine.connect.return_value = mock_connection
datasource.database.get_sqla_engine.return_value.__enter__.return_value = (
mock_engine
datasource.database.get_raw_connection.return_value = (
mock_conn
)

command = StreamingCSVExportCommand(query_context)
Expand All @@ -264,35 +243,91 @@ def test_empty_result_set(mocker: MockerFixture) -> None:


def test_catalog_and_schema_passed_to_engine(mocker: MockerFixture) -> None:
"""Test that catalog and schema are forwarded to get_sqla_engine.

Prequeries (e.g. SET search_path for PostgreSQL) are now run automatically
via a connect event listener registered inside get_sqla_engine, not by the
streaming command itself.
"""
"""Test that catalog and schema are forwarded to get_raw_connection."""
mock_db, query_context, datasource = _setup_chart_mocks(
mocker, catalog="my_catalog", schema="my_schema"
)

mock_result = mocker.MagicMock()
mock_result.keys.return_value = ["col1"]
mock_result.fetchmany.side_effect = [[("val",)], []]
mock_cursor = mocker.MagicMock()
mock_cursor.description = [("col1",)]
mock_cursor.fetchmany.side_effect = [[("val",)], []]

mock_connection = mocker.MagicMock()
mock_connection.execution_options.return_value.execute.return_value = mock_result
mock_connection.__enter__.return_value = mock_connection
mock_connection.__exit__.return_value = None
mock_conn = mocker.MagicMock()
mock_conn.cursor.return_value = mock_cursor

mock_engine = mocker.MagicMock()
mock_engine.connect.return_value = mock_connection
datasource.database.get_sqla_engine.return_value.__enter__.return_value = (
mock_engine
datasource.database.get_raw_connection.return_value = (
mock_conn
)

command = StreamingCSVExportCommand(query_context)
list(command.run()())

datasource.database.get_sqla_engine.assert_called_once_with(
datasource.database.get_raw_connection.assert_called_once_with(
catalog="my_catalog",
schema="my_schema",
)


def test_sql_mutation_applied_before_execution(mocker: MockerFixture) -> None:
"""Test that mutate_sql_based_on_config is called before executing SQL.

Regression test for #40465: the streaming export path was executing raw
SQL without applying SQL_QUERY_MUTATOR config, leaving trailing semicolons
unstripped for engines like Trino that reject them.
"""
mock_db, query_context, datasource = _setup_chart_mocks(mocker)
datasource.database.mutate_sql_based_on_config.return_value = "SELECT 1 LIMIT 10"

mock_cursor = mocker.MagicMock()
mock_cursor.description = [("col1",)]
mock_cursor.fetchmany.side_effect = [[(1,)], []]

mock_conn = mocker.MagicMock()
mock_conn.cursor.return_value = mock_cursor

datasource.database.get_raw_connection.return_value = (
mock_conn
)

command = StreamingCSVExportCommand(query_context)
list(command.run()())

# SQL mutation must be called before execution
datasource.database.mutate_sql_based_on_config.assert_called_once_with(
"SELECT * FROM test"
)
# The mutated SQL (not the original) should be sent to the cursor
mock_cursor.execute.assert_called_once_with("SELECT 1 LIMIT 10")


def test_get_raw_connection_used_instead_of_get_sqla_engine(
mocker: MockerFixture,
) -> None:
"""Test that get_raw_connection is used for proper user impersonation.

Regression test for #40465: the streaming export path used
get_sqla_engine() directly, bypassing user impersonation. This meant
all streaming CSV exports ran as the service principal instead of the
logged-in user, breaking audit trails and potentially bypassing
per-user authorization (Ranger, OPA, RLS views).
"""
mock_db, query_context, datasource = _setup_chart_mocks(mocker)

mock_cursor = mocker.MagicMock()
mock_cursor.description = [("col1",)]
mock_cursor.fetchmany.side_effect = [[("val",)], []]

mock_conn = mocker.MagicMock()
mock_conn.cursor.return_value = mock_cursor

datasource.database.get_raw_connection.return_value = (
mock_conn
)

command = StreamingCSVExportCommand(query_context)
list(command.run()())

# Must use get_raw_connection (handles impersonation, SSH, OAuth2)
datasource.database.get_raw_connection.assert_called_once()
# Must NOT use get_sqla_engine directly (bypasses impersonation)
datasource.database.get_sqla_engine.assert_not_called()
Loading