Skip to content

Commit 798f358

Browse files
authored
Fix Power BI DirectQuery slicers on text, interval, time and timestamptz columns (#11)
2 parents be49c0b + 197cf66 commit 798f358

26 files changed

Lines changed: 1521 additions & 395 deletions

‎.github/workflows/build.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,8 @@ jobs:
310310
# per layer: once a single layer exhausts it, the pull fails and takes
311311
# every other image with it. Layers that did arrive stay in the local
312312
# content store, so a second attempt refetches only what is missing and
313-
# costs seconds. The spooling leg is the one that reaches quay.io for
314-
# MinIO, and that is where the dropped connections have been seen.
313+
# costs seconds. The spooling leg pulls the most, MinIO on top of the
314+
# core images, so it is the one most exposed to a dropped connection.
315315
- name: Pull the stack images
316316
# From the stack directory, as scripts/lib.sh's `compose` wrapper does.
317317
# Compose derives the project name from the compose file's directory,

‎CHANGELOG.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- `timestamp with time zone` values are delivered as wall time in the session
13+
time zone instead of in UTC: `TimeZone=` when set, otherwise the
14+
coordinator's default, and whatever a later `SET TIME ZONE` chose. Power BI
15+
folds the value it showed back as a plain `TIMESTAMP` literal, which Trino
16+
reads in the session zone, so a DirectQuery slicer on such a column only
17+
selected its rows in a UTC session. Applications reading these columns see
18+
different values unless the session is UTC. `time with time zone` values
19+
follow the same zone, at its current offset because a time has no date, which
20+
is how Trino itself casts `TIME` to `TIME WITH TIME ZONE`.
21+
- The Power BI connector reports that Trino cannot convert `TIME` to
22+
`TIMESTAMP` (`SQL_CONVERT_TIME` without `SQL_CVT_TIMESTAMP`). A DirectQuery
23+
slicer on a time column folded to a comparison against Power BI's base date,
24+
30 December 1899, while Trino anchors a cast time on the current date, so the
25+
report silently showed no rows. Power BI now refuses that fold with a visible
26+
error instead. The README describes a view-based workaround.
27+
28+
### Fixed
29+
30+
- `SQLGetTypeInfo` lists the plain type first among the rows that share a
31+
`DATA_TYPE`: `VARCHAR` for `SQL_WVARCHAR`, `TIME` for `SQL_TYPE_TIME` and
32+
`TIMESTAMP` for `SQL_TYPE_TIMESTAMP`, as the spec's "how closely the data type
33+
maps" ordering requires. The rows were sorted by name, so `SQL_WVARCHAR` led
34+
with `INTERVAL DAY TO SECOND`, and Power Query, which takes the first row as
35+
its `CAST` target, folded a DirectQuery slicer on a text column into
36+
`CAST(... AS INTERVAL DAY TO SECOND)`, which Trino rejected.
37+
- The Power BI connector quotes text constants and doubles any `'` in them.
38+
Power Query hands the constant over unquoted, so a slicer value folded into
39+
`CAST(hello world as VARCHAR)` and failed. Together with the `SQLGetTypeInfo`
40+
fix above this makes DirectQuery slicers on text columns work, so the driver
41+
and the connector have to be upgraded together.
42+
- `INTERVAL YEAR TO MONTH` and `INTERVAL DAY TO SECOND` columns read as text now
43+
return Trino's own rendering, the same text `CAST(... AS VARCHAR)` produces
44+
(`-1-0`, `0 00:00:00.500`). They were parsed into fields and re-rendered
45+
(`-1-00`, `0 00:00:00.5`), so a Power BI DirectQuery slicer on an interval
46+
column, which folds to `cast(col as VARCHAR) = '<shown value>'`, silently
47+
selected no rows. Reading these columns as `SQL_C_INTERVAL_*` still works:
48+
stackable-odbc-core now converts interval text to those C types.
49+
1050
## [0.1.2] — 2026-09-01
1151

1252
### Changed

‎Cargo.lock‎

Lines changed: 16 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ snafu = "0.9"
3838
# reviewable edit to this line instead of whatever the branch happens to point
3939
# at. To build against a local checkout, use a `[patch]` in your own
4040
# `.cargo/config.toml` rather than editing this line; see CONTRIBUTING.md.
41-
stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", tag = "v0.1.0" }
41+
stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", tag = "v0.1.1" }
4242
# `time` is needed directly by `query_all_rows_within`, which bounds the login
4343
# round trip with `tokio::time::timeout`. It resolves without being declared,
4444
# because reqwest enables it, but a direct use must not rely on another crate's
@@ -60,7 +60,7 @@ serial_test = "4"
6060
# offline FFI tests need. It is default-off because it is test code, so it is
6161
# enabled here rather than on the [dependencies] entry above -- that keeps it
6262
# out of the shipped cdylib.
63-
stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", tag = "v0.1.0", features = ["test-support"] }
63+
stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", tag = "v0.1.1", features = ["test-support"] }
6464

6565
[lints.clippy]
6666
unwrap_in_result = "deny"

‎README.md‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ proper entry in the **Get Data** dialog instead of the generic ODBC one.
7878
2. In **File > Options > Security**, allow any extension to load.
7979
3. Restart Power BI Desktop. **Stackable Trino** now appears under **Get Data**.
8080

81+
For incremental refresh on a `timestamp with time zone` column, Trino compares
82+
the `RangeStart` and `RangeEnd` bounds in the session time zone (`TimeZone`),
83+
so partition boundaries follow that zone. Changing `TimeZone` on a dataset that
84+
already has partitions moves the boundaries.
85+
8186
### Your first query
8287

8388
Assuming a Trino instance is reachable on the given host and port:
@@ -148,7 +153,7 @@ The authoritative list is `src/backend/types/connect_params.rs`.
148153
| `Roles` | No | Authorisation role per catalog, `{catalog:role;catalog2:ALL}` |
149154
| `SessionUser` | No | User statements run as, while `User` still authenticates. JDBC's `sessionUser` |
150155
| `Path` | No | Default SQL path for resolving unqualified function names |
151-
| `TimeZone` | No | IANA session time zone (`Europe/Berlin`). Unset leaves the coordinator's |
156+
| `TimeZone` | No | IANA session time zone (`Europe/Berlin`). Unset leaves the coordinator's default. `timestamp with time zone` and `time with time zone` values are delivered as wall time in the session zone, which a later `SET TIME ZONE` changes |
152157
| `Locale` | No | Locale for locale-dependent formatting, sent as `X-Trino-Language` |
153158
| `ClientInfo` | No | Free-form client metadata Trino records against the query |
154159
| `TraceToken` | No | Correlation token Trino records against the query |
@@ -259,6 +264,19 @@ ignored, so the tool can react instead of trusting a wrong answer.
259264
somewhere you never asked for. Set `Catalog` when you connect.
260265
- **Row and field size limits are not faked.** Trino can only limit a result set
261266
through `LIMIT` in the SQL you wrote.
267+
- **Power BI cannot filter on a time column in DirectQuery.** Picking a value
268+
in a slicer on a `time` column fails with "We couldn't fold the expression to
269+
the data source". Power BI would filter by casting the column to a timestamp
270+
and comparing it with that time on 30 December 1899, while Trino, like ODBC
271+
itself, puts a cast time on today's date, so the filter could never match.
272+
The connector declares the cast unsupported so you see an error instead of an
273+
empty report. To slice on a time of day, expose it as text in a Trino view,
274+
for example `CAST(col_time AS VARCHAR) AS col_time_text`, and slice on that
275+
column. Selecting "(Blank)" still works. Users who cannot create views can
276+
switch the table to Import mode, where the slicer filters Power BI's own copy
277+
of the data. That copy is only as fresh as its last refresh, and Power BI
278+
treats a blank time as equal to midnight, so selecting 12:00:00 AM also shows
279+
rows without a time.
262280
- **One isolation level.** Trino catalogs disagree about which levels they
263281
accept, so the driver offers the one they all support and refuses the rest up
264282
front, rather than letting a query fail later for a reason nobody can see.
@@ -307,6 +325,22 @@ the coordinator's chain is refused even when the machine trusts that chain.
307325
**Only the first session property applies.** Wrap the value in braces. See
308326
[Values that contain a semicolon](#values-that-contain-a-semicolon).
309327

328+
**A Power BI slicer misses a timestamp from the night the clocks go back.**
329+
`timestamp with time zone` values are shown in the session time zone, and in
330+
the hour that repeats, two instants share one wall time. Power BI filters on the
331+
wall time it showed and Trino reads that as the later of the two instants, so a
332+
value from the first of the repeated hours is not selected.
333+
334+
**Large reads fail with `ABANDONED_QUERY` or `Query not found`.** Trino
335+
abandons a query whose results the client has not fetched within
336+
`query.client.timeout` (5 minutes by default), and later forgets it altogether,
337+
after which the next fetch returns `404 Not Found: Query not found`. A client
338+
that reads slowly enough gets there on a large result. One cause on
339+
Windows is ODBC tracing left switched on: it writes every call to a file and
340+
slows reads down considerably. Turn it off in the ODBC Data Source
341+
Administrator (Tracing tab, **Stop Tracing Now**), including on an on-premises
342+
data gateway.
343+
310344
**The browser login never opens.** Some tools, `pyodbc` among them, tell the
311345
driver it may not display anything. The driver reports this rather than hanging.
312346
Use `AccessToken` with those tools, or connect through one that allows a prompt.

‎connector/StackableTrinoODBC.pq‎

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,10 @@ StackableTrinoODBCImpl = (
307307
SupportsTop = false
308308
],
309309

310-
// Nothing is overridden. An override here silently wins over
311-
// SQLGetInfoW and cannot be corrected by fixing the driver, so the
312-
// record is reserved for what the driver gets wrong, and this group it
313-
// answers honestly: SQL_SQL92_PREDICATES, SQL_AGGREGATE_FUNCTIONS,
310+
// One entry is overridden, SQL_CONVERT_TIME, below. An override here
311+
// silently wins over SQLGetInfoW and cannot be corrected by fixing the
312+
// driver, so the record is otherwise reserved for what the driver gets
313+
// wrong, and this group it answers honestly: SQL_SQL92_PREDICATES, SQL_AGGREGATE_FUNCTIONS,
314314
// SQL_SQL92_RELATIONAL_JOIN_OPERATORS, SQL_SQL92_VALUE_EXPRESSIONS and
315315
// SQL_IDENTIFIER_QUOTE_CHAR.
316316
//
@@ -327,7 +327,22 @@ StackableTrinoODBCImpl = (
327327
// Nothing Power Query generates is lost: comparison, IN,
328328
// LIKE, BETWEEN, IS NULL, EXISTS and the four join types are all in
329329
// the driver's ungated set.
330-
SQLGetInfo = defaultConfig[SQLGetInfo],
330+
//
331+
// SQL_CONVERT_TIME is the driver's answer (every SQL_CVT_* bit) minus
332+
// SQL_CVT_TIMESTAMP, and that is a deliberate misreport: Trino can
333+
// cast TIME to TIMESTAMP. It anchors the time on the current date, as
334+
// ODBC's own conversion tables do ("SQL to C: Time", footnote [c]),
335+
// while Power BI folds a time slicer to
336+
// `cast(col as TIMESTAMP) = CAST('1899-12-30 hh:mm:ss' as TIMESTAMP)`,
337+
// anchoring on its own base date. That filter ran and silently matched
338+
// no row. Without the bit, Power BI refuses the fold and says so ("We
339+
// couldn't fold the expression to the data source"), which is the
340+
// honest outcome for a filter Trino cannot evaluate the way Power BI
341+
// means it. test_folding_contract.py pins the value to the driver's
342+
// answer and checks Trino's anchor is still the current date.
343+
SQLGetInfo = defaultConfig[SQLGetInfo] & [
344+
SQL_CONVERT_TIME = 0x01FDFFFF
345+
],
331346

332347
SQLColumns = (catalogName, schemaName, tableName, columnName, source) =>
333348
source,
@@ -394,12 +409,16 @@ StackableTrinoODBCImpl = (
394409
// rendering it as Trino's `X'..'` literal
395410
// cannot be verified without Power BI Desktop.
396411
//
412+
// Power Query hands a text-valued constant to the visitor
413+
// bare, not quoted: confirmed in Power BI Desktop on
414+
// 2026-10-07, where `Cast(_, "VARCHAR")` folded a slicer
415+
// into `CAST(hello world as VARCHAR)`. A text entry therefore
416+
// quotes the value itself and doubles any single quote in
417+
// it, or `O'Brien` ends the literal early.
418+
//
397419
// TODO: add UUID, JSON, TIME WITH TIME ZONE and TIMESTAMP
398-
// WITH TIME ZONE. Each has a valid Trino cast, but the
399-
// rendering turns on whether Power Query hands a
400-
// text-valued constant to the visitor already quoted, which
401-
// the VARCHAR entry below also rests on. Confirm that in
402-
// Power BI Desktop, then add all four.
420+
// WITH TIME ZONE, now that the quoting question above is
421+
// settled. Each has a valid Trino cast.
403422
Visitor = [
404423
DECIMAL = each Cast(_, "DECIMAL"),
405424
INTEGER = each Cast(_, "INTEGER"),
@@ -410,7 +429,7 @@ StackableTrinoODBCImpl = (
410429
DOUBLE = each Cast(_, "DOUBLE PRECISION"),
411430
BOOLEAN = each Cast(_, "BOOLEAN"),
412431
DATE = each Cast(Quote(Date.ToText(_, "yyyy-MM-dd")), "DATE"),
413-
VARCHAR = each Cast(_, "VARCHAR"),
432+
VARCHAR = each Cast(Quote(Text.Replace(_, "'", "''")), "VARCHAR"),
414433
// `fffffff`, not `sssssss`: in a custom format string
415434
// `s` is the second and `f` is the fractional second,
416435
// so the latter spelling renders the second eight times

‎fuzz/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ one: a value a coordinator legitimately sent must fail safe, and where a
2020
release build has no overflow checks the same defect returns a wrong answer
2121
instead of an error.
2222

23-
- `json_value` covers `json_to_column_value` and the dozen temporal, interval
24-
and decimal scanners under it. This is the half of the read path core does
23+
- `json_value` covers `json_to_column_value` and the temporal and decimal
24+
scanners under it. This is the half of the read path core does
2525
not see: core fuzzes `write_column_value`, which turns the resulting
2626
`ColumnValue` into the caller's buffer, and nothing covered the step that
2727
produces it.

‎fuzz/fuzz_targets/json_value.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ use trino_rust_client::{TrinoFloat, TrinoInt, TrinoTy};
1111
//
1212
// stackable-odbc-core already fuzzes the second half (`write_column_value`,
1313
// ColumnValue -> the caller's buffer). Nothing covered the step before it,
14-
// which is where this crate's temporal, interval and decimal parsers live:
15-
// roughly a dozen hand-written scanners over text a Trino coordinator chose.
14+
// which is where this crate's temporal and decimal parsers live: hand-written
15+
// scanners over text a Trino coordinator chose.
1616
// Every one of them runs on the server's side of the trust boundary.
1717
//
1818
// The property is that no input panics. A panic here is caught at the FFI

‎integration-tests/scripts/gen-trino-config.sh‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ set -euo pipefail
1919
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
2020

2121
OUT="$GENERATED/trino"
22-
rm -rf "$OUT"
22+
# Emptied in place, never removed: a running coordinator bind-mounts this
23+
# directory, and replacing it would leave the container holding the deleted
24+
# one, empty, so a restart would find no config at all. setup.sh recreates
25+
# Trino when the assembled content changes (TRINO_CONFIG_HASH).
26+
mkdir -p "$OUT"
27+
find "$OUT" -mindepth 1 -delete
2328
mkdir -p "$OUT/catalog"
2429

2530
cp -r "$STACK_DIR/trino/base/." "$OUT/"

0 commit comments

Comments
 (0)