How the driver translates statements
graph TD
A["SQL statement of the application (Access, ADO.NET, VBA)"] --> B{"Can it be expressed as an OData request?"}
B -->|yes| C["OData request to the service: $select, $filter, $orderby, $top, $skip, $count, $apply, POST/PATCH/DELETE"]
B -->|no| D{"Does the connection or the service name an address for custom statements?"}
D -->|yes| E["Unchanged to the database (pass-through, with bound values)"]
D -->|no| F["Rejected with SQLSTATE 42000 and a message that names the reason"]The driver computes nothing itself. Every statement is either translated into an OData request, passed through to the database, or rejected – it never loads a whole table to filter, group, or join locally. That explains why a query in Access runs against one service and ends with „ODBC–call failed“ against another.
Key features
- Reading, filtering, sorting, and counting on the service. Conditions, sorting, column selection, and
COUNT(*)go to the service as query options; only the rows found travel. - Editing as with a SQL Server table.
INSERT,UPDATE, andDELETEfrom datasheets and forms becomePOST,PATCH, andDELETE, with write conflict detection throughIf-Match. - Totals and groups without pass-through, if the service announces grouping.
- Paging with
OFFSET … FETCHas in SQL Server. - Long conditions in the body instead of the address.
- Everything else unchanged to the database, if the connection offers that – otherwise a clear rejection instead of a wrong result.
Basic concepts
Translated
| The application sends | The driver asks the service |
|---|---|
SELECT a, b FROM t WHERE x = ? ORDER BY a | GET t?$select=a,b&$filter=x eq …&$orderby=a |
… WHERE x LIKE '%Keller%' | $filter=contains(x,'Keller') (likewise x% → startswith, %x → endswith) |
… WHERE Nr IN (?, ?, …) | or-chain Nr eq … or Nr eq … |
SELECT COUNT(*) FROM t WHERE … | GET t/$count?$filter=… – without loading rows |
… ORDER BY a OFFSET 500 ROWS FETCH NEXT 200 ROWS ONLY | $orderby=a&$skip=500&$top=200 |
SELECT g, SUM(x) FROM t GROUP BY g HAVING … | $apply=groupby((g),aggregate(x with sum as …))&$filter=…, only if the service announces $apply |
SELECT … WHERE Key = ? | $filter=Key eq …—reading always goes through the set, never through the key address |
INSERT INTO t … | POST t |
UPDATE t SET … WHERE Key = ? AND RowVersion = ? | PATCH t(…) with If-Match |
DELETE FROM t WHERE Key = ? AND RowVersion = ? | DELETE t(…) with If-Match |
UPDATE/DELETE of a table without a key over all old values | bound actions UpdateByOriginal/DeleteByOriginal, if the service offers them |
An address that would become longer than MAXURLLENGTH goes as POST …/$query with the options in
the body. The complete rules are under
Supported SQL: Grouping and summarizing
and Supported SQL: Paging with OFFSET/FETCH.
Passed through
What can't be translated – a join of two tables, a subquery, a change to many rows, a schema
statement, calculating between aggregates – the driver sends unchanged to the address for own
statements. Bound values remain values: the ? placeholders become named parameters. The service
describes the columns of the result; the driver doesn't guess types.
The address is named either by the connection (PASSTHROUGH) or by the service in its schema
document. Whether the statement may be executed is decided by the service, not by the driver;
the driver doesn't check or classify it. The reason is under
Security model and trust boundaries;
the setup under Set up pass-through queries.
One exception: statements over tables with the prefix MSys – the application's system tables, for
example the Access query for MSysConf on every connection – are rejected by the driver itself. They
don't exist in the service, and Access expects exactly this answer.
Rejected
Without an address for custom statements, the driver rejects everything that can't be translated with
SQLSTATE 42000. Access shows this as error 3146 „ODBC–call failed“ and then doesn't compute the
query itself – not even for totals and joins that it could compute itself over a local table. See
"ODBC--call failed" for totals or joins.
Likewise rejected instead of approximated – and, with an address, likewise passed through: LIKE
with _ or a character class ([a-c]), functions other than SUM, MIN, MAX, AVG, COUNT in
a grouping, changing or deleting a table without a row version over the old values of all columns if
the service declares neither a concurrency property nor the actions for tables without a key for it.
The reason is always the same: a silently simplified statement would return a different result that
would look like the answer.
Transactions
Every statement is a separate request to the service. The driver brackets several of them only if
the service offers transactions: it then names the address Store.Transactions in $metadata (the
data service Wkl.Data.Gateway does this).
| Service | SQL_TXN_CAPABLE | Autocommit off (BeginTrans, BeginTransaction) |
|---|---|---|
with Store.Transactions | SQL_TC_ALL, isolation READ COMMITTED | With the first request, the driver opens a transaction on the service; every further request of the connection – reading, writing, custom statements – runs in it and sees its own changes that aren't saved yet. SQLEndTran saves or discards them; disconnecting discards them |
| without | SQL_TC_NONE | HYC00, instead of an apparently opened transaction |
Access and DAO decide solely by SQL_TXN_CAPABLE whether to pass BeginTrans on to the driver.
Without transactions from the service, they keep the transaction to themselves: every change is
saved on the service immediately, and a Rollback takes nothing back there – without an error. With
them, VBA code with BeginTrans/Rollback behaves as it does through the SQL Server driver:
Rollback takes back changes, additions, and update queries, CommitTrans saves them, and an error
in one statement leaves the transaction in place.
Differences from the SQL Server driver: the transaction holds a connection of its own on the
service; if the application doesn't use it for a while, the service rolls it back, and CommitTrans
then reports 40001 – none of it is saved. How long the service waits is up to the service; for the
data service Wkl.Data.Gateway, the default is two minutes.
Check before use whether transactions take effect
If an application relies on Rollback – for example an operation that creates a header and its
line items together – check with the service it's going to run against:
- Query the capability: in DAO
CurrentDb.OpenRecordset("<verknüpfte Tabelle>").Transactions(must beTrue), in ODBCSQLGetInfo(SQL_TXN_CAPABLE)(must beSQL_TC_ALL). - Try it on a test row:
BeginTrans, change the row,Rollback, read the row again. It must carry the old value.
If step 1 shows False, Rollback takes nothing back on the service, without an error. An
operation that may save several records only together then belongs in a database procedure that it
calls through pass-through, or it needs a service with transactions.
What happens on errors
| Situation | Result | What the application must do |
|---|---|---|
| A statement in the transaction fails (for example a violated key) | The statement reports the error; the transaction stays open, and the other changes in it remain – as through the SQL Server driver | decide for itself: Rollback, or save the rest with CommitTrans |
| The service has rolled back the transaction (idle time, transaction that has become unusable) | CommitTrans reports 40001; nothing is saved | start the operation again |
The connection breaks during CommitTrans | 08S01. The driver doesn't repeat a commit: whether anything was saved is unknown | read the affected records again; if the commit didn't reach the service, the service rolls back the transaction after its idle time |
The connection breaks during Rollback | 08S01; nothing is saved, because the service rolls back the open transaction after its idle time at the latest | nothing; the records are free again once the idle time has elapsed |
| The service doesn't offer transactions | ADO.NET: HYC00 on BeginTransaction; DAO/Access: every change is saved immediately, Rollback has no effect | see the check above |
Database messages
If the database rejects a statement and the service passes on its messages (error.details with
the message number as code, as the data service Wkl.Data.Gateway does), the driver reports them as
the SQL Server driver does: one diagnostic record per message, the message number as the native
error number, and the SQLSTATE that the service names for it (23000 for a violated key, reference,
or required value, 22001 for a value that's too long, 01000 for an accompanying informational
message).
| Application | sees |
|---|---|
| Access, message box | the text of the first message, followed by its number: … (#547) |
| VBA/DAO | DBEngine.Errors(0).Number = message number, .Description = text; the last entry is, as always, the Access error (3146) |
| ADO.NET | OdbcException.Errors[i].NativeError and .SQLState |
Code that branches on the number over a direct connection
(If DBEngine.Errors(0).Number = 2627 Then) therefore runs unchanged. The service passes the text on
only if it names nothing the caller isn't allowed to see; otherwise a text of the service appears
there, and the number and SQLSTATE are still correct. Without details, the driver reports a single
record with the HTTP status as the number.
Why it works this way: alternatives considered
The division into translating, passing through, and rejecting is a design decision. Three alternatives would have distributed correctness, cost, or security very differently:
| Alternative | What it would have brought | Why it was rejected |
|---|---|---|
| Compute locally: the driver loads the rows and filters, groups, or joins itself | Every query would run, even against a service without grouping and without pass-through | The cost grows with the table, not with the result, and stays invisible until the table is large. Moreover, the driver would compute with its own rules for collation, NULL, and rounding, not with those of the database – the result would differ without notice |
| Pass through only: every statement goes to the database unchanged | The database's full SQL language, no translation rules | The driver would be tied to a service that offers such an address, and to its SQL dialect; every caller would need the right to execute custom statements. A passed-through statement is also read in full, without next pages |
| Translate only, reject everything else | No way around the service's rules | Access doesn't compute itself after a rejection, even if SQLGetInfo denies the capability. Joins and totals over linked tables would then always fail |
The chosen form combines the last two: whatever can be expressed as an OData request runs against
any OData v4 service and under its per-table rules; only the rest needs the address for own
statements. Its price: a query that neither route can carry fails visibly instead of running slowly
or inaccurately. Approximating individual parts, for example LIKE 'a_c' as contains, is rejected
as well; the reason is under Rejected.
Related topics
- Security model and trust boundaries – who allows a passed-through statement