Compare commits

...

2 Commits

Author SHA1 Message Date
Evan
b34bed64d5 fix(sqllab): correct post_results event logger action name; validate rows > 0
The POST /results/ handler was logging its event as `.get_results` instead
of `.post_results`, making the two endpoints indistinguishable in audit logs.

The `rows` field in SqlLabResultsSchema now rejects values < 1. Negative
values produce surprising slice semantics (`data[:-1]`) and zero is silently
ignored, so only positive integers are meaningful.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 18:18:39 -07:00
Claude Code
eec8dbce47 feat(sqllab): accept results key via POST body
The SQL Lab results endpoint previously accepted the cached results key
(and optional row limit) only via the query string on a GET request. This
adds a POST variant of `/api/v1/sqllab/results/` that reads `{ "key": ...,
"rows": ... }` from the JSON request body, sharing the exact same handler
logic and `can_get_results` permission as the GET. The frontend now sends
the key in the POST body so it no longer appears in the query string.

The existing GET endpoint is retained unchanged for backward compatibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:45:11 -07:00
5 changed files with 172 additions and 11 deletions

View File

@@ -103,7 +103,7 @@ describe('async actions', () => {
beforeEach(() => {
dispatch = jest.fn();
fetchMock.removeRoute(fetchQueryEndpoint);
fetchMock.get(
fetchMock.post(
fetchQueryEndpoint,
JSON.stringify({
data: mockBigNumber,
@@ -450,6 +450,23 @@ describe('async actions', () => {
});
});
test('sends the results key in the POST body and not the URL', () => {
expect.assertions(4);
return makeRequest().then(() => {
const calls = fetchMock.callHistory.calls(fetchQueryEndpoint);
expect(calls).toHaveLength(1);
const call = calls[0];
expect(call.options.method?.toUpperCase()).toBe('POST');
// The results key must not leak into the query string
expect(call.url).not.toContain(query.resultsKey);
expect(JSON.parse(call.options.body as string)).toEqual({
key: query.resultsKey,
rows: null,
});
});
});
/* oxlint-disable-next-line jest/no-disabled-tests */
test.skip('parses large number result without losing precision', () =>
makeRequest().then(() => {
@@ -481,7 +498,7 @@ describe('async actions', () => {
expect.assertions(1);
fetchMock.removeRoute(fetchQueryEndpoint);
fetchMock.get(
fetchMock.post(
fetchQueryEndpoint,
{ throws: { message: 'error text' } },
{ name: fetchQueryEndpoint },

View File

@@ -482,14 +482,14 @@ export function fetchQueryResults(
const { SQLLAB_QUERY_RESULT_TIMEOUT } = getState().common?.conf ?? {};
dispatch(requestQueryResults(query));
const queryParams = rison.encode({
key: query.resultsKey,
rows: displayLimit || null,
});
const timeout = timeoutInMs ?? SQLLAB_QUERY_RESULT_TIMEOUT;
const controller = new AbortController();
return SupersetClient.get({
endpoint: `/api/v1/sqllab/results/?q=${queryParams}`,
return SupersetClient.post({
endpoint: `/api/v1/sqllab/results/`,
jsonPayload: {
key: query.resultsKey,
rows: displayLimit || null,
},
parseMethod: 'json-bigint',
...(timeout && { timeout, signal: controller.signal }),
})

View File

@@ -56,6 +56,7 @@ from superset.sqllab.schemas import (
QueryExecutionResponseSchema,
sql_lab_get_results_schema,
SQLLabBootstrapSchema,
SqlLabResultsSchema,
)
from superset.sqllab.sql_json_executer import (
ASynchronousSqlJsonExecutor,
@@ -85,6 +86,7 @@ class SqlLabRestApi(BaseSupersetApi):
estimate_model_schema = EstimateQueryCostSchema()
execute_model_schema = ExecutePayloadSchema()
format_model_schema = FormatQueryPayloadSchema()
results_model_schema = SqlLabResultsSchema()
apispec_parameter_schemas = {
"sql_lab_get_results_schema": sql_lab_get_results_schema,
@@ -95,6 +97,7 @@ class SqlLabRestApi(BaseSupersetApi):
ExecutePayloadSchema,
FormatQueryPayloadSchema,
QueryExecutionResponseSchema,
SqlLabResultsSchema,
SQLLabBootstrapSchema,
)
@@ -493,8 +496,62 @@ class SqlLabRestApi(BaseSupersetApi):
$ref: '#/components/responses/500'
"""
params = kwargs["rison"]
key = params.get("key")
rows = params.get("rows")
return self._get_results_response(
key=params.get("key"), rows=params.get("rows")
)
@expose("/results/", methods=("POST",))
@protect()
@permission_name("get_results")
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post_results",
log_to_statsd=False,
)
def post_results(self) -> FlaskResponse:
"""Get the result of a SQL query execution.
---
post:
summary: Get the result of a SQL query execution
description: >-
Accepts the cached results key (and optional row limit) in the
request body instead of the query string.
requestBody:
description: The cached results key and optional row limit
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SqlLabResultsSchema'
responses:
200:
description: SQL query execution result
content:
application/json:
schema:
$ref: '#/components/schemas/QueryExecutionResponseSchema'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
410:
$ref: '#/components/responses/410'
500:
$ref: '#/components/responses/500'
"""
try:
params = self.results_model_schema.load(request.json)
except ValidationError as error:
return self.response_400(message=error.messages)
return self._get_results_response(key=params["key"], rows=params.get("rows"))
def _get_results_response(self, key: str, rows: Optional[int]) -> FlaskResponse:
result = SqlExecutionResultsCommand(key=key, rows=rows).run()
# Using pessimistic json serialization since some database drivers can return

View File

@@ -14,7 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from marshmallow import fields, Schema
from marshmallow import fields, Schema, validate
from superset.databases.schemas import ImportV1DatabaseSchema
@@ -27,6 +27,19 @@ sql_lab_get_results_schema = {
}
class SqlLabResultsSchema(Schema):
key = fields.String(
required=True,
metadata={"description": "The results key of the cached query results"},
)
rows = fields.Integer(
required=False,
allow_none=True,
validate=validate.Range(min=1),
metadata={"description": "The maximum number of rows to return"},
)
class EstimateQueryCostSchema(Schema):
database_id = fields.Integer(
required=True, metadata={"description": "The database id"}

View File

@@ -474,6 +474,80 @@ class TestSqlLabApi(SupersetTestCase):
app.config["RESULTS_BACKEND_USE_MSGPACK"] = use_msgpack
@mock.patch("superset.commands.sql_lab.results.results_backend_use_msgpack", False)
def test_post_results_matches_get(self):
"""The POST endpoint returns the same payload as the GET endpoint and
accepts the results key (and optional row limit) via the request body."""
from superset.commands.sql_lab import results as command
command.results_backend = mock.Mock()
self.login(ADMIN_USERNAME)
data = [{"col_0": i} for i in range(100)]
payload = {
"status": QueryStatus.SUCCESS,
"query": {"rows": 100},
"data": data,
}
expected_full = {"status": "success", "query": {"rows": 100}, "data": data}
expected_limited = {
"status": "success",
"query": {"rows": 100},
"data": data[:1],
"displayLimitReached": True,
}
query_mock = mock.Mock()
query_mock.sql = "SELECT *"
query_mock.database = 1
query_mock.schema = "superset"
use_msgpack = app.config["RESULTS_BACKEND_USE_MSGPACK"]
app.config["RESULTS_BACKEND_USE_MSGPACK"] = False
serialized_payload = sql_lab._serialize_payload(payload, False)
compressed = utils.zlib_compress(serialized_payload)
command.results_backend.get.return_value = compressed
with mock.patch("superset.commands.sql_lab.results.db") as mock_superset_db:
mock_superset_db.session.query().filter_by().one_or_none.return_value = (
query_mock
)
# GET (backward compatible) with the key in the query string
get_resp = json.loads(
self.get_resp(
f"/api/v1/sqllab/results/?q={rison.dumps({'key': 'key'})}"
)
)
# POST with the key in the request body
post_rv = self.client.post(
"/api/v1/sqllab/results/",
json={"key": "key"},
)
post_resp = json.loads(post_rv.data.decode("utf-8"))
# POST honors the optional row limit
post_limited_rv = self.client.post(
"/api/v1/sqllab/results/",
json={"key": "key", "rows": 1},
)
post_limited = json.loads(post_limited_rv.data.decode("utf-8"))
assert post_rv.status_code == 200
assert get_resp == expected_full
assert post_resp == expected_full
assert post_limited == expected_limited
app.config["RESULTS_BACKEND_USE_MSGPACK"] = use_msgpack
def test_post_results_requires_key(self):
"""The POST endpoint rejects requests missing the required results key."""
self.login(ADMIN_USERNAME)
rv = self.client.post("/api/v1/sqllab/results/", json={})
resp_data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 400
assert resp_data == {"message": {"key": ["Missing data for required field."]}}
@mock.patch("superset.models.sql_lab.Query.raise_for_access", lambda _: None) # noqa: PT008
@mock.patch("superset.models.core.Database.get_df")
def test_export_results(self, get_df_mock: mock.Mock) -> None: