Materialised cursor temps — why they are ADT, and what breaks if you change it
Some SELECT shapes cannot be served by a live cursor over the source table, so the engine materialises the result into a temp table and hands back a cursor over that instead. This note explains the constraints that pin the temp’s on-disk format, because two of them are invisible until a specific customer scenario breaks, and both have already been regressed once.
If you are about to change the format of a materialised temp, read this
first. The relevant code is exec_sql_direct_impl() in
src/abi/ace_exports.cpp (search for _srt_).
What materialises, and where
| Path | Temp | Site | Format |
|---|---|---|---|
Single-table SELECT with ORDER BY / DISTINCT / LIMIT / TOP |
_srt_* |
exec_sql_direct_impl() |
ADT ✅ |
EXECUTE PROCEDURE declared outputs (__output) |
_spout_* |
run_dd_procedure() |
ADT ✅ |
| Joins (2-table and N-way), unions, aggregates | various | same file | DBF ⚠️ still truncates |
A plain SELECT ... [WHERE ...] does not materialise — it stays a live
cursor. That asymmetry is why a bug here often looks like “the query works until
I add ORDER BY”.
The two fixed paths were fixed separately, months apart, for the same root
cause. Fixing one and assuming the other was covered is exactly how the
__output truncation survived: sp_GetPhysicalPath was still returning
databasepa after the _srt_ path was corrected. The join/union/aggregate
materialisers are still on DBF — same constraint matrix applies when you get to
them (joins additionally rename right-side columns R_*, itself a consequence
of squeezing R_ + the source name into 10 bytes).
The three constraints, and the format they force
| # | Requirement | Why | Rules out |
|---|---|---|---|
| 1 | Result must be a standalone table, not the live source | An application that runs INDEX ON / DBSETORDER over the result must not rewrite the source’s production index. A real customer hit exactly this: “cualquier SELECT-SQL reescribio los indices originales” (#146). |
a live cursor with a recno sequence |
| 2 | Column names must survive in full | A DBF field descriptor gives the name 11 bytes, so a DBF temp silently truncates every column to 10 chars. SAP’s own catalog names are routinely longer — RI_Primary_Table (16), Enable_Internet (15), User_Defined_Prop (17), Trig_Function_Name (18) — so a DBF temp turned SELECT Name, RI_Primary_Table FROM system.relations ORDER BY Name into a result whose column was called RI_Primary, breaking every client that then referenced it by name. User tables with long columns were mangled identically. |
a DBF temp |
| 3 | Numeric scale must survive | N(12,2) has to read back as "10.50", not "10.5". An ADT DOUBLE (type 10) stores no decimal count in its descriptor — and stamping one there makes the real ADS engine reject the table as corrupt (error 7016), so this is not something we can simply start writing. Reported by an ERP whose prices are N(12,2) (#146). |
an ADT temp whose numerics map to DOUBLE |
Constraints 2 and 3 are in direct tension, which is the trap: DBF satisfies 3 but not 2, and the obvious ADT mapping satisfies 2 but not 3.
The format that satisfies all three is an ADT temp whose decimal numerics use
ADT type 2 (numeric stored as ASCII digits, which carries the declared scale
in the value itself). That is what the AsciiNumeric field-type name exists
for; see field_spec_for() / adt_spec_for() in src/abi/ace_exports.cpp.
History — both halves have regressed before
- #136 materialised these queries into a memory table. Full names, but it retyped every numeric as a 4-byte integer and capped records at 64 KB, so it dropped decimals and failed outright on a wide table.
- #146 fixed the typing by switching to an on-disk temp — created as
ADS_CDX, i.e. a DBF. That fixed constraint 3 and silently broke constraint 2 for every column name over 10 characters. - The current code uses ADT +
AsciiNumeric, satisfying all three.
Copying rows into the temp
Values are copied with the setter that matches how the target field is stored, not always the string one:
Numeric/Floatare ASCII in both DBF and ADT, soas_stringcarries the declared scale exactly and must be written as a string.Integer,Double,Currency,ShortInt,AutoInc,AdtMoneyare raw little-endian binary on ADT. Writing a string into one stores the text verbatim, so"10.50"reads back as ~6.01e-154. This never showed on the old DBF temp because DBF stores numerics as ASCII, so the string write round-tripped by accident.
Cleanup
drop_materialised_cursor_temp() deletes the temp when the cursor handle
closes. Its extension list must cover every format a materialiser can emit
(.adt/.adm/.adi today, plus the DBF-era extensions so temps left by an
older build are still removed). Anything missing leaks one file per query into
the customer’s data directory.
A note on __output specifically
Procedure outputs have no declared scale — openads::script::Type is
Char/Integer/Double/Logical/Date/Timestamp — so constraint 3 does not bite
there and no AsciiNumeric mapping is needed. Constraint 2 very much does: a
procedure may be reading ADT tables or declaring outputs that mirror SAP catalog
columns, and those names run past 10 characters routinely.
run_dd_procedure() builds the temp through CREATE TABLE … AS FREE TABLE,
which takes its format from the statement’s table type and defaults to CDX
(i.e. DBF). It therefore pins ADS_ADT across that one DDL and restores the
caller’s setting afterwards — the procedure body may run its own
CREATE TABLE and must not silently inherit our choice.
Because the temp is ADT, sql_type_of() no longer clamps CHAR to 254 (DBF’s
character maximum). An over-long record is now rejected by AdsCreateTable
(“ADT record too long”) instead of being silently shortened.
Regression tests
tests/unit/abi_sql_orderby_test.cpp— “materialised cursors keep column names longer than 10 chars” covers constraints 2 and 3 together, acrossORDER BY,DISTINCTandTOP.tests/unit/abi_sql_orderby_types_test.cpp— numeric scale (#146).tests/unit/abi_script_proc_test.cpp— “script proc:__outputkeeps column names longer than 10 chars” covers the procedure path, and also asserts the value is reachable by the full name, which is what a truncated descriptor actually breaks for callers.
Each was verified to fail with its fix reverted — they guard, they do not merely pass. If one fails with a truncated name or a re-rendered number, a materialising path has gone back to a format that cannot carry both. Do not adjust the expectations.
Known related defect (not fixed here)
AdsSetString into an ADT DOUBLE field stores the text verbatim instead of
parsing it, so the value reads back as garbage. Reachable by creating an ADT
table with Numeric,12,2 (which maps to DOUBLE) and writing with
AdsSetString. Tracked in TODO.parity.md.