shivamgoel commented on issue #37535:
URL: https://github.com/apache/superset/issues/37535#issuecomment-5417587818
## Revised proposal — supersedes the original above
The original predates the merged Semantic Layer work, and several
assumptions no longer hold. Reviewed with
@villebro. Draft implementation: #43527.
**Changed from the original:** the semantic-layer pull requests are no
longer a blocking dependency, they
merged; no command in `superset-core`, which contains no query execution; no
type-dispatch layer, since
`Explorable.get_query_result` already dispatches; built on
`QueryContextFactory` rather than
`QueryObjectFactory`; `SemanticQuery` supplies the vocabulary, not the wire
type; `dimensions` / `limit` /
`offset` / `order` replace the earlier field names; ad-hoc metrics are
explicitly in scope; `arrow` is a
per-request opt-in rather than deferred; and the separate capabilities
endpoint is dropped.
## Motivation
Querying a datasource by its semantic definitions today means `POST
/api/v1/chart/data`, which is built for
the Explore user interface. Callers hand-construct a `query_context` —
`BASE_AXIS` adhoc columns,
`post_processing` pivot and flatten chains, `extras`, `orderby` tuples —
which is frontend implementation
detail, not an API. Nothing validates names, so a misspelled metric surfaces
as a SQL error or an empty
result. And the payload follows Explore, so external consumers inherit every
change made for visualization
reasons. In practice they reverse-engineer a `query_context` from a saved
chart's `form_data`, which breaks
whenever the chart type changes.
The right abstraction already exists: the `Explorable` protocol
(`superset/explorables/base.py`), implemented
by both `SqlaTable` and the semantic-layer `SemanticView`, whose
`get_query_result` already routes to SQL or
to a semantic-layer provider. What's missing is a REST surface over it.
## Proposed Change
Two routes on the **existing** `DatasourceRestApi`, which already serves
this shape for `/compatible`,
`/column/<col>/values/` and `/validate_expression`, so no new API class or
permission view menu is needed:
```
POST /api/v1/datasource/<type>/<id>/query
GET /api/v1/datasource/<type>/<id> (metadata + capabilities)
```
`<type>` is a `DatasourceType` — `table` or `semantic_view`.
```
request
v
DatasourceQuerySchema.load() invalid type -> 400
| semantic_view + flag off -> 404
v
resolve_explorable() datasource lookup + raise_for_access()
v
validate_query_names() unknown name -> 400 + suggestion
v
build_query_dict() request vocabulary -> QueryObject
vocabulary
v
execute_tabular_query() QueryContextFactory.create()
| set_query_context_form_data()
| ChartDataCommand.validate() <-
authorization gate
| ChartDataCommand.execute()
v
Explorable.get_query_result() SqlaTable -> SQL
SemanticView -> mapper -> provider
```
Entering the pipeline at `QueryContextFactory` is the central design choice:
result caching, row-level
security, post-processing, time offsets, event logging and row-limit
clamping are all applied at or below that
boundary, so they come along unchanged. Nothing in the query engine is
modified.
### The shared core
Resolve, validate, build and execute live in
`superset/common/tabular_query.py`. `resolve_explorable` reads
`Explorable.metrics` and `.columns`, so both datasource types take **one**
code path.
The REST layer owns this core as the stable versioned contract; other
surfaces consume it. That matters now,
because two Model Context Protocol tools each implement the sequence inline
and have already drifted:
| | `get_table` | `query_dataset` |
| --- | --- | --- |
| `set_query_context_form_data` (Jinja macros) | no | yes |
| `custom_cache_timeout` honoured | no | yes |
| dimension field name | `dimensions` | `columns` |
| `extras.time_grain_sqla` | no | no |
Row one is a live bug: the same query against a Jinja-templated virtual
dataset returns **different results**
through the two tools. Both are refactored onto the shared core, and their
existing unit tests pass
**unmodified** — the evidence the refactor preserves behaviour.
## New or Changed Public Interfaces
```json
{
"metrics": ["sum__sales", {"expressionType": "SQL",
"sqlExpression": "SUM(a)/SUM(b)", "label":
"Ratio"}],
"dimensions": ["region", "product_category"],
"filters": [{"col": "region", "op": "IN", "val": ["EMEA", "APAC"]}],
"time_range": "Last 30 days",
"time_column": "order_date",
"time_grain": "P1D",
"limit": 1000,
"offset": 0,
"order": [{"column": "sum__sales", "descending": true}],
"result_format": "json"
}
```
| Field | Notes |
| --- | --- |
| `metrics`, `dimensions` | `List(Raw)`, because `Metric` is `AdhocMetric \|
str` — identical to `ChartDataQueryObjectSchema`, so ad-hoc expressions work on
datasets at no cost. Semantic views reject them by design, since the provider
owns metric definitions; surfaced as 400 naming the metric, never a 500 |
| `filters` | Reuses `ChartDataFilterSchema`, which already validates
against `FilterOperator` |
| `time_column`, `time_grain` | A column plus an ISO 8601 duration. The
grain is applied through a `BASE_AXIS` adhoc column, because
`extras.time_grain_sqla` is read only by the semantic-layer mapper and
**ignored for datasets** — setting just that would silently return unbucketed
rows. No resolvable temporal column returns 400 |
| `limit`, `offset`, `order` | `SemanticQuery`'s names rather than
`QueryObject`'s, with per-column sort direction mirroring its `OrderTuple`.
Translation happens once, in `build_query_dict`, keeping the contract
independent of the execution model |
| `result_format` | `json` (default) or `arrow` |
JSON returns the conventional `{"result": [{data, colnames, coltypes,
rowcount, is_cached, ...}]}` envelope.
`arrow` returns an Apache Arrow stream as
`application/vnd.apache.arrow.stream`. Arrow needs no feature flag,
since it is selected per request; no cache change, since serialization
happens at response time and both
formats share cache entries; and is deliberately excluded from
`table_like()`, so it is not gated behind
export permissions — it is a programmatic transport, not a file export.
`GET /api/v1/datasource/<type>/<id>` returns `datasource.data` plus a
`capabilities` block built from
properties that already exist on `Explorable`: `is_rls_supported`,
`query_language`, `supports_samples`,
`supports_drill_to_detail`, `supports_adhoc_metrics`, `time_grains`,
`supported_operators`, and `features`.
Compatible metric and dimension discovery is not duplicated — `POST
…/compatible` already provides it with a
security-aware cache.
### Operators
Three sets exist and should not be conflated: the wire set
(`FilterOperator`, whose values are `==` rather
than `EQUALS`, and which includes `TEMPORAL_RANGE`), the driver set
(`Operator` in `superset-core`), and what
the mapper accepts. `TEMPORAL_RANGE` never reaches a driver — the mapper
expands it into `>=` and `<` bounds.
The endpoint accepts the full wire set and returns 400 naming the operator
when a datasource cannot express it.
`ILIKE` and `NOT_ILIKE` previously **raised** for semantic views, with a
comment arguing that collapsing
case-sensitivity onto the backend's collation diverges from the operator the
author chose, and a test
asserting the rejection. This proposal implements them instead; if a
provider cannot express
case-insensitive matching, the fallback is a `SemanticViewFeature` gate,
which already exists for this.
### Security, permissions and feature flag
`@protect()` and `raise_for_access()` guard the route, but
`ChartDataCommand.validate()` is the real gate —
it applies query-context authorization and row-level security, so no second
authorization path is written.
Errors are sanitized for low-privilege callers. Guest tokens are out of
scope. Per `SECURITY.md`, this grants
nothing beyond what datasource read access, `raise_for_access` and row-level
security already permit through
`/api/v1/chart/data`.
Both routes share one `can_query on Datasource` permission, reachable by the
Gamma role because `"can_query"`
is added to `READ_ONLY_PERMISSION` — the same treatment `can_get_drill_info`
already has. Without it,
`Datasource` being listed in `GAMMA_READ_ONLY_MODEL_VIEWS` makes
`_is_alpha_only` withhold the permission.
The metadata route is deliberately **not** named `get`, because
`PUBLIC_ROLE_PERMISSIONS` already grants
`("can_get", "Datasource")` for chart rendering, and a method named `get`
would expose datasource metadata to
unauthenticated users wherever `PUBLIC_ROLE_LIKE = "Public"`.
*Related pre-existing issue, out of scope:* `can_get_column_values` and
`can_compatible` on this class are
subject to the same exclusion and are unreachable by Gamma today.
Consolidating them is a breaking permission
change needing a migration.
`superset.datasource.api.query` is added to `WTF_CSRF_EXEMPT_LIST`, as
`/api/v1/chart/data` already is, since
a token-authenticated client has no cross-site request forgery token to
send; without it every non-browser
caller receives *"The CSRF token is missing"*. Stated plainly rather than
buried: with
`allow_browser_login = True` this means a browser session cookie alone can
drive the endpoint cross-site — the
same trade-off `/api/v1/chart/data` accepts, with the same mitigation, being
read-only and behind the
authorization gate above.
The route is always registered. `SEMANTIC_LAYERS` (default off) gates only
the semantic-view branch: `table`
always works, `semantic_view` returns 404 when off. Gating the whole route
would make the dataset case
unreachable in nearly every deployment.
## What this changes
Three new files — `superset/common/tabular_query.py` and two test modules —
and twelve modified:
| File | Change |
| --- | --- |
| `superset/datasource/api.py` | two new methods |
| `superset/datasource/schemas.py` | one new schema |
| `superset/mcp_service/**/{get_table,query_dataset}.py` | call the shared
core |
| `superset/security/manager.py` | `can_query` in `READ_ONLY_PERMISSION` |
| `superset/config.py` | query route exempted from cross-site request
forgery checks |
| `superset-core/.../semantic_layers/types.py` | `ILIKE` / `NOT_ILIKE` on
`Operator` |
| `superset/semantic_layers/mapper.py` | implement those instead of raising |
| `superset/common/chart_data.py` | `ARROW` on `ChartDataResultFormat` |
| `superset/common/query_context_processor.py` | Arrow serializer branch |
| `superset/explorables/base.py` | declare `raise_for_access` on the
protocol |
| `tests/unit_tests/semantic_layers/mapper_test.py` | replace the rejection
test |
The `explorables/base.py` change is worth noting: every explorable
implements `raise_for_access` and callers
depend on it, but the protocol never declared it, so type checking rejects
the call. Safe at runtime, since
nothing performs an `isinstance` check against the protocol.
**Untouched:** `QueryObject`, `QueryContext`, `QueryContextFactory`,
`QueryObjectFactory`, `ChartDataCommand`,
`DatasourceType`, the frontend, and `/api/v1/chart/data`.
**None of:** database migrations, new feature flags, new API classes, new
view menus, new dependencies.
`pyarrow` is already a direct dependency, so Arrow adds nothing.
## Migration Plan and Compatibility
Purely additive at the HTTP layer, no database migrations. One configuration
default changes
(`WTF_CSRF_EXEMPT_LIST`), requiring no deployment action.
`/api/v1/chart/data` is unchanged and remains the
endpoint for chart-shaped queries, `post_processing`, `annotation_layers`
and asynchronous execution.
Two internal changes disclosed rather than hidden: a name-validation helper
relocates out of the Model Context
Protocol package, with a one-release re-export at the old path; and the two
tools lose their inline
implementations. Their public schemas are unchanged, but `get_table`
**gains** Jinja and cache-timeout
correctness — a fix that changes results for anyone relying on the broken
behaviour, so it warrants an
`UPDATING.md` note. Deployments that grant permissions explicitly rather
than through role bundles need
`can_query on Datasource`.
## Rejected and deferred
**`SemanticQuery` as the wire format.** It holds `pyarrow` data types and
*resolved* `Dimension` and `Metric`
objects rather than names, so it is the output of resolution rather than a
serializable input; this proposal
takes its terminology, not its type.
**`SemanticQuery` as the single internal query representation — deferred.**
The core `SemanticView` interface
has no implementations in the repository, so datasets would need a
`SqlaTable`-backed driver, effectively a
second SQL generator; and caching, row-level security, post-processing and
clamping all sit above the driver,
so such a path would re-implement them or re-enter this pipeline.
`tabular_query.py` is the seam if it is
pursued later, and the payload would not change.
**Building on `QueryObjectFactory`.** It is invoked inside
`QueryContextFactory`; entering below that forfeits
everything listed under Proposed Change.
**A command in `superset-core`.** No query execution exists there; that
refactor belongs to SIP-187.
**A separate capabilities endpoint.** Every field already exists on
`Explorable` or is served by `/compatible`.
**Per-type endpoints and aliases** such as `/dataset/<id>/query`. One schema
with `<type>` in the path is
simpler, and dispatch already exists below the API layer.
**Group limits and asynchronous execution.** Both need extra wire fields and
capability negotiation; the
payload is forward-compatible with either.
## Verification
Covered by unit tests and manual verification against the examples dataset:
existing Model Context Protocol
tests pass unmodified; the Jinja divergence resolves across all three
surfaces; `can_query on Datasource`
exists after `superset init` and the Public role gains neither route;
`ILIKE` on a semantic view returns rows
rather than raising; an ad-hoc metric on a semantic view returns 400 naming
it; omitting `result_format`
returns JSON; `arrow` round-trips to the same rows as JSON and shares its
cache entry; `time_grain` emits a
real date truncation with a matching `GROUP BY`; `semantic_view` returns 404
with the flag off; `limit` above
the schema cap returns 400; a repeated query reports a cache hit and `force`
bypasses it.
**Two items are not yet covered in #43527** and need integration rather than
unit tests: that row-level
security applies to queries issued through this endpoint, and that a Gamma
user with dataset access can call
it while the Public role cannot.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]