Three quiet fidelity limits in a T-SQL result grid
Wide integers and high-scale decimals arrive as JavaScript numbers, only the first result set of a batch is read, and one paged statement is not recognised.
A result grid that is wrong loudly is a bug report. A result grid that is wrong quietly is a decision someone will make on Tuesday. The SQL Server provider in LibreDB Studio has three such quiet places, and all three are written down on the provider page rather than smoothed over. Two of them hand back a plausible wrong answer instead of an error, and the third fails a statement that looks correct.
How values cross the wire into the grid
The SQL Server provider is built on the mssql driver (node-mssql, Tedious over
TDS), default port 1433, in
src/lib/db/providers/sql/mssql.ts. Running a statement takes a Request from the
pool, binds parameters as @p1, @p2 and so on through request.input(), and
returns one object:
{ rows: recordset, fields, rowCount: rowsAffected[0] ?? recordset.length, executionTime, columnTypes? }
Two things in that shape matter for the rest of this post. rows is
result.recordset - singular. And columnTypes is filled from the map the driver
attaches to the recordset, using T-SQL’s own lowercase spelling of the declared
type: bigint, decimal, nvarchar, uniqueidentifier, varbinary. That is
declaration, not the driver’s class name, because
INFORMATION_SCHEMA.COLUMNS.DATA_TYPE answers bigint and not BigInt, so the
type on a computed column reads the same as the type in the schema tree. It was
measured on SQL Server 2022 CU26 over a probe table.
Everything after the driver is JSON. That is where the fidelity goes.
Exact numerics that stop being exact: bigint and decimal precision loss at the client
BIGINT, DECIMAL, NUMERIC and MONEY are surfaced as JavaScript numbers.
Beyond 2^53, or at high scale, they can lose precision. That is the whole of it: no
error, no warning marker on the cell, a number that is very close to the right one.
The failure has a shape worth recognising. A BIGINT primary key generated by a
snowflake scheme sits comfortably above 2^53, so two adjacent ids can render as the
same digit string in the grid. A DECIMAL(19,4) ledger column sums correctly on the
server and arrives as a binary float, so the total you copy out of the grid and the
total the server computed can differ in the last place. Both look like data. Neither
looks like a defect.
Preserving them means fetching them as strings, and the provider does not do that
for you. What it does give you is the declared type beside the column, which is the
signal to stop trusting the digits: if columnTypes says bigint, decimal,
numeric or money, the value in that cell went through a JavaScript number on the
way to your screen. The statement can do what the provider does not, by casting the
column to NVARCHAR before it leaves the server:
SELECT
CAST(order_id AS NVARCHAR(20)) AS order_id_text,
CAST(amount AS NVARCHAR(32)) AS amount_text
FROM sales.orders;
The same inference problem exists on the way in. Parameters are bound with
request.input() and no SQL type, so the driver infers the TDS type from the
JavaScript value. The provider doc records that inference as a foot-gun and names
three values it can guess wrong: null, very large integers, and VARCHAR against
NVARCHAR intent. Binding a type explicitly is not currently exposed.
The batch that returns more than you see
query() reads result.recordset - singular, the first result set and no other. So
a multi-statement batch, or a stored procedure that returns several result sets,
surfaces exactly one of them in the grid.
The server does not skip the rest. It runs the entire batch; what the provider hands on is the first set, and the others do not reach the grid. Run this and the grid shows the customers:
SELECT id, name FROM dbo.customers;
SELECT id, total FROM dbo.orders;
There is no row saying a second set existed, because nothing in the returned shape
records that one did. The count under the grid does not help either - rowCount is
rowsAffected[0], the first statement’s affected-row count, falling back to the
length of the recordset. On a batch, that is a number about the first statement
sitting under a grid you may be reading as the whole answer.
The workaround is the one it sounds like: run the statements as separate tabs, or call the procedure in a way that returns one set. It is a real limitation, not a style preference about batches, and it is on the provider page in those words.
A paged statement the limiter does not recognise
Before a SELECT reaches the server it passes a limiter, so an unbounded query does
not pull a table into a browser. On T-SQL that means one of two splices: TOP n
into the head after SELECT [DISTINCT], or OFFSET m ROWS FETCH NEXT n ROWS ONLY
appended at the tail, with ORDER BY (SELECT NULL) injected when the statement has
no ordering of its own, because T-SQL requires one there.
To avoid doubling a bound that already exists, the limiter first asks whether the statement is already paged. Those probes read a literal count. So this is recognised and left alone:
SELECT id, name FROM dbo.customers ORDER BY id
OFFSET 10 ROWS FETCH NEXT 50 ROWS ONLY;
and this is not:
SELECT id, name FROM dbo.customers ORDER BY id
OFFSET @skip ROWS FETCH NEXT @take ROWS ONLY;
The parameterised page still reads as unbounded, so it collects a TOP - and SQL
Server rejects a TOP beside an OFFSET ... FETCH outright, with Msg 10741. The
statement fails rather than returning too many rows. The provider doc records this as
a known limitation rather than as a decision, and names the fix: accept a variable or
an expression as the count.
The limiter is conservative in the same direction elsewhere. Where it cannot read a statement’s end confidently, it declines to splice and reports no limit rather than reporting one it did not apply. It never claims a bound while handing the statement back unchanged.
Binary columns, and what they cost in response size
VARBINARY, IMAGE and rowversion are not stringified by the provider. They come
back as Node buffers and cross the wire as {"type":"Buffer","data":[...]}. The
client recovers them: the results grid, the row detail sheet and the CSV export all
classify that shape as binary and render it as \x hex, in
src/lib/export/binary.ts.
So this one is not a correctness limit. It is a size limit. A byte of binary data
travels as roughly four bytes of JSON digits, which means a SELECT * over a table
with a photo column is a much larger response than the row count suggests. Project
the columns you want, or wrap the binary in a length or a hash on the server when all
you need is to tell two rows apart.
Wide integers, decimals and money values are surfaced as JavaScript numbers and can lose precision; only one result set of a multi-statement batch is surfaced; and a parameterised paged statement is not recognised as already bounded, so the statement fails. Those three sentences are the SQL Server entry’s cost of admission. They are on the engine pages and in the published feature boundaries, and we would rather you read them now than reconcile a ledger against them later.