Color scheme: Light
You are here: start » en » Documentation » Data integration and data access » WKL Database Gateway » Reference » HTTP: OData v4 (reference)

HTTP: OData v4 (reference)

The OData v4 interface under /odata/v4/—for Access via the ODBC driver, Excel and Power BI via Power Query, and any other OData client. It reads tables and views, writes individual records, aggregates via $apply and describes itself in the schema document.

Syntax

A second interface over the same catalog, for clients that speak OData v4 instead of the native API: Power Query in Excel and Power BI, and an ODBC driver for OData through which Access links tables. Such a client learns everything from the schema document and follows the annotations of the protocol. It doesn't know the window convention or the key in the query part of the native API. Sign-in, permissions, rate limiting, audit and error format are the same as on the native API (see HTTP: error format and HTTP: sign-in).

MethodPathMeaning
GET/odata/v4/list of databases (aliases)
GET/odata/v4/{alias}/list of their schemas
GET/odata/v4/{alias}/{schema}/service document
GET/odata/v4/{alias}/{schema}/$metadataschema document (XML)
GET/odata/v4/{alias}/{schema}/{set}$select $filter $orderby $top $skip $count $apply
GET/odata/v4/{alias}/{schema}/{set}({key})one record; $select only
GET/odata/v4/{alias}/{schema}/{set}/$countcount; $filter only
POST/odata/v4/{alias}/{schema}/{set}[/$count]/$querythe same, options in the body
POST/odata/v4/{alias}/{schema}/$transactionsopen a transaction (extension, see below)
POST/odata/v4/{alias}/{schema}/$transactions/{id}/commitcommit the transaction
POST/odata/v4/{alias}/{schema}/$transactions/{id}/rollbackroll the transaction back
QUERY/odata/v4/{alias}/{schema}/{set}[/$count]like POST …/$query, options in the body
POST/odata/v4/{alias}/{schema}/{set}body: fields; response 201 + record
PATCH/odata/v4/{alias}/{schema}/{set}({key})body: fields; response 200 + record
DELETE/odata/v4/{alias}/{schema}/{set}({key})response 204
POST/odata/v4/{alias}/{schema}/{set}/UpdateByOriginaltables without a key only, see below
POST/odata/v4/{alias}/{schema}/{set}/DeleteByOriginaltables without a key only, see below

Parameters

The service answers PUT with 405, as on the native API.

Which databases and schemas a caller can use is stated by the two lists above the service roots—for clients that offer a selection, such as the connection dialog of the ODBC driver, like the database list in the SQL Server dialog. It's an ordinary service document of the protocol whose entries have the kind ServiceDocument—the kind that OData provides for referring to another service (OData JSON Format 4.01, section “Service Document”). A generic client can follow them without knowing this service:

{"@odata.context":"https://daten.example.com/odata/v4/$metadata",
 "value":[{"name":"fachdaten","kind":"ServiceDocument","url":"fachdaten/"}]}
RuleWhy
Only what the caller may read is listed: an alias if a rule allows them tables or views there; a schema if it contains at least one readable table or viewThe list reveals nothing the caller can't reach anyway
Anyone who may read nothing gets an empty list of aliases; an alias with nothing readable responds 404Like a service root with nothing readable
The list of aliases comes solely from the configuration; that of the schemas from the catalogThe first one doesn't open a database
With a SQL Server login alone, the list of aliases responds 401The password can only be checked at the server of an alias; the schemas of a named alias are reachable
Both lists have a $metadata without entity setsA service document refers to one

Writes go to tables and views, and always exactly one record. The statement is built from the columns of the catalog, as with the native API; the same rules apply unchanged. The caller determines the record (via the key in the address) and the values (via the body), never a condition. If the key is missing from the address, the request is rejected instead of hitting the whole set. A view accepts a change if the caller's rules allow views with this verb and SQL Server considers the view updatable—as with the SQL Server ODBC driver, through which Access edits linked views; if SQL Server refuses, the service responds 422. Columns of a view that SQL Server can't update—a scalar subquery, an expression, an aggregate—are reported by the service as read-only (Core.Computed); it rejects a change that names one of them with 400 and the name of the column. Exceptions are views with an INSTEAD OF trigger: there the trigger decides what such a column means. Access doesn't adopt this information: a linked view still offers such a column for editing, with this driver as with SQL Server's ODBC Driver 18, and if you change it, the refusal also discards the other changes to the record. Where users edit in Access, link the base table for writing and the view only for reading. The native API (cf) writes only to tables.

Every write names one record by its complete key. The only exception is released tables without a key: there the caller names the record by its old values, and even then exactly one record is written.

The response is the stored record with its new ETag; on create, additionally the address of the new record in the Location header. This way the caller knows identity keys, default values and computed columns without having to read the record again.

A key that the database assigns itself—as an identity or by a default such as NEWSEQUENTIALID()—may be omitted on create; the response and Location name the assigned value.

ResponseMeaning
201 / 200 + recordcreated / updated
204deleted
400key incomplete, field unknown or not writable, value doesn't match the type
403the caller may not write this table or view in this database
404no record with this key
409key already taken on create; for the actions for tables without a key: the old values match several records
412If-Match names an outdated row version; nothing was written
422the database refuses (constraint, trigger, permissions under Service), see error format
429a limit of the service
503service or database disrupted. Whether the change was saved is then unknown—read the record again
504time limit exceeded; whether anything was saved is also unknown

A timestamp with a time zone is converted to Coordinated Universal Time for a column without a zone and stored as wall-clock time: …T17:00:00Z as 17:00, …T17:00:00+02:00 as 15:00. It isn't rejected here (unlike on the native route); see Timestamps, numbers, and names.

Fields with @ in their name—@odata.etag, @odata.type—are skipped instead of rejected. A client that sends back a record it has read includes them; they aren't columns.

One service root per alias and schema. In OData, an entity set is named like a simple identifier, and the objects of a database are only unique per schema. With the schema in the root, the set has exactly the same name as the object (APPARAT), without a separator that could also occur in a real name.

The schema document applies per caller. It names tables if the caller may read tables of the alias, and views if they may read views—nothing that would be refused to them. Functions and procedures don't appear. A schema without a readable object, including a mistyped one, responds with 404 and not with an empty service.

let
    Quelle = OData.Feed("https://gateway.example.com/odata/v4/fachdaten/dbo/", null,
        [Implementation = "2.0", Headers = [#"X-Api-Key" = "…"]]),
    Apparate = Quelle{[Name = "APPARAT"]}[Data],
    Keller = Table.SelectRows(Apparate, each Text.Contains([Benennung], "Keller"))
in
    Keller

In this example, the key is in plaintext in the query and therefore in every copy of the workbook. That's a limitation of sign-in by header, not of this interface.

With Windows sign-in, the header isn't needed: OData.Feed("https://gateway.example.com/odata/v4/fachdaten/dbo/", null, [Implementation = "2.0"]), plus the authentication kind “Windows” in Power Query's credentials dialog.

Key. The key of a set is the primary key, otherwise the first unique index—but only if none of its columns may be empty and all have a type that the protocol allows as a key. A view without its own index gets the key that SQL Server names for its result (sp_describe_first_result_set in browse mode, is_part_of_unique_key)—the same information from which the SQL Server ODBC driver derives the key of a view. It's adopted only if the view shows all these columns: if SQL Server has to add a key column of a base table that the view doesn't select (a join), the visible columns alone don't identify a record. A view without such a key is offered without a key: readable and filterable, but no single record is addressable. The protocol actually expects one; without this exception, precisely the views through which a client is supposed to read would be unreachable.

A view is described by its result, not by its stored column list. A view over SELECT * whose tables have changed since keeps the old list until someone runs sp_refreshview, and SQL Server delivers the new columns under the old names, so values arrive under the wrong column names and with the wrong type. The service therefore describes every view via sys.dm_exec_describe_first_result_set, as the ODBC driver describes a result, and writes a warning to the log once per view.

What the service says about the database. Whatever OData doesn't know but a client needs in order to behave toward an application like the product's driver is given as an annotation of a vocabulary of its own in $metadata. The vocabulary (namespace Wkl.Store.V1, alias Store) is available without sign-in under /odata/v4/$vocabulary/Wkl.Store.V1.xml; a client that doesn't know it skips the annotations and loses nothing.

AnnotationWhereContent
Store.Product, Store.ProductVersioncontainerMicrosoft SQL Server, 17.00—major and minor version, without build
Store.Schemacontainerschema of the service root as the database names it (dbo); the ODBC driver reports it as TABLE_SCHEM, from which Access forms the source name of linked tables
Store.PassThroughcontaineraddress for custom statements; only for a caller who may use them: alias with AllowPassThrough and rule with sql or statement under the caller's own login, or alias with AllowCheckedStatements (default) for checked statements
Store.Typepropertytype name in the database, for an identity column with identity (int identity)
Store.Indexesentity typeall rowstore indexes with name, Unique, Primary, Clustered and the columns in key order; filtered and disabled ones not
Store.Rows, Store.Pagesentity typerows and data pages of the table or indexed view (heap or clustered index, in-row pages), the same numbers that sp_statistics reports to ODBC; state at catalog load, so estimates. Missing for views without an index
Core.Computedpropertynot writable: identity, computed column, row version, row stamp, and columns of a view that SQL Server reports as not updatable

Power Query in Excel for Microsoft 365 reads the extended schema document unchanged.

Rows and pages are planning aids, not a count. Access reads them via SQLStatistics when linking and plans its joins accordingly. Without a page count, Access may resolve a join by looking up the rows of one table individually by key, one request for each row of the other side; with a page count, it sends the join in one statement. The row count alone doesn't change the plan. Both numbers come from catalog views that SQL Server maintains continuously; reading them costs equally little regardless of table size and needs no additional permission.

The key specification Menge(4711) or Menge(Auftrag=7,Zeile='a') is read with the filter grammar and then checked for its form: exactly one equality comparison per key column, nothing else. APPARAT(1 or nummer ne 0) is valid as an expression and would hit every row; it's rejected with 400.

Types and encoded names: Timestamps, numbers, and names.

What Power Query shows (Excel, OData.Feed with Implementation="2.0"):

Display
Column namesas stored—Power Query decodes them itself, for example F&E and Prüf-Kennung, including names with ", . and [
Names of tables and views in navigationencoded, such as gw_x0020_Tabelle_x0020__x0026__x0020_Co; you can rename the query in Power Query
Filters over such columnspassed to the service, for example [#"F&E"] <> null and [#"Prüf-Kennung"] = "F", instead of loading the whole table

Row version. If a table has one, every record carries it as @odata.etag (W/"<Base64>"), a single record also in the ETag header. The schema document announces the column with Core.OptimisticConcurrency. The value is the same one that If-Match accepts when writing. For editable tables, the column isn't optional: a client that wants to change a record otherwise has nothing to attach a precondition to. That's why this product's OData ODBC driver refuses update and delete without a row version instead of silently overwriting a change made by someone else. For a table without a row version, the row stamp takes its place if it's turned on for it.

Next pages are offered by the service according to the same rules as nextLink in the native API, here as @odata.nextLink and as an absolute address, as the protocol intends. Behind an upstream proxy, the proxy therefore has to pass on scheme and host (X-Forwarded-* with the corresponding middleware); otherwise context and next addresses point to the host this process sees.

If the set is ordered by its key—no $orderby from the caller, a key of integers, text or uniqueidentifier, all key columns selected—the next page continues after the key of the last row ($skiptoken, such as ?$skiptoken=3691 or ?$skiptoken=Auftrag=7,Zeile='a') instead of after a row count ($skip). $skip becomes OFFSET … ROWS, and SQL Server counts all previous rows again for every page, so each page takes longer than the one before. With $skiptoken, the time per page stays the same. The token is opaque to the client; the service reads it like the key specification of an address and rejects it with 400 for a read request ordered differently. In all other cases, $skip is used. A decimal key is deliberately excluded: read through the filter grammar, its value could be rounded.

Page size as the client prefers. Without $top, the service cuts a read into pages of the configured size (Gateway:Limits:DefaultTop, 1,000 rows by default). A client that reads narrow rows, such as only the keys of a table, can ask for larger pages with the header Prefer: odata.maxpagesize=10000. The service applies the size within MaxTop and MaxRows of the object, confirms it in the Preference-Applied header, and still offers the next page: unlike $top, the preference doesn't end the read. An explicit $top takes precedence. The ODBC driver asks for a size itself, based on the width of the selected columns.

Next pages don't count against the request limit (Gateway:Security:RateLimit)—here as in the own contract. For this, every next address carries the option wkl-page, a check value over caller, path, all other options and an expiry time (15 minutes). The key for it is generated randomly when the process starts and never leaves it. Reason: a client reads a large set page by page, each page is a request, and an abort with 429 in the middle of reading would leave a table incomplete in Access with “ODBC–call failed.” So only the start of a read counts. The rules:

CaseEffect
First retrieval of a next address that the service issued to this callerdoesn't count
The same retrieval again (repetition after a connection drop, loop)counts like any request
Self-built $skip/$skiptoken, changed option, tampered or expired check valuecounts like any request
Check value from another process (after a restart, another instance behind a load balancer)counts like any request

Nothing is ever rejected because of wkl-page: an invalid check value only removes the exemption; the service checks sign-in and rights on every page as usual. The option appears only in addresses that the service builds itself; a client passes it on without reading it (it doesn't start with $).

One deviation from the protocol and one addition are based on how Power Query behaves:

DefinitionReason
Deviation: binary values as ordinary Base64 instead of base64url, which OData JSON Format 4.01 specifies in the section “Primitive Value”The OData library of the Office clients reads only the ordinary alphabet; with base64url, loading a table with a row version fails (“Cannot convert the literal … to the expected type 'Edm.Binary'”)
Addition: Capabilities.FilterFunctions names contains, startswith, endswithWithout this information, Power Query evaluates Text.Contains itself and loads the whole table to do so; with it, the condition goes to the service as $filter. Comparisons, ranges, and timestamps go out as $filter even without it

Transactions

An extension of this service, not part of OData. For several changes in one step, OData provides the $batch with change sets; it has no transaction across several individual requests, as ODBC applications expect. A generic OData client doesn't use this extension; it's intended for the ODBC driver and programs that address it explicitly.

A client brackets several requests in a transaction, like an application via the SQL Server driver with BeginTrans/CommitTrans/Rollback. The service announces this in the schema document (Store.Transactions on the entity container, the address below); only then does the ODBC driver report transactions.

RequestResponse
POST {Dienststamm}/$transactions201 with {"id": "…", "idleSeconds": 120}
any request with the header Wkl-Transaction: <id>—reading, writing, procedures, custom statements (sql.json)runs in the transaction; sees its own changes that aren't saved yet
POST {Dienststamm}/$transactions/<id>/commit204; 409 if the database has already rolled back the transaction—then none of it is saved; 503 if the connection to the database broke off during commit—then the outcome is unknown
POST {Dienststamm}/$transactions/<id>/rollback204
an unknown, ended or foreign ID404
DefinitionReason
The transaction holds its own connection, as the caller's login, isolation READ COMMITTEDThat way it behaves as via the SQL Server driver: its own changes visible, locks until the end
Only the caller who opened it, and only on the same aliasAn ID isn't a right; every statement in it is judged by the caller's rules as without a transaction
A statement that fails—on the version check (412) or because the database refuses it (constraint, message from a trigger)—fails on its own; the transaction continuesAs via the SQL Server driver. The generated statement rolls back to its own savepoint
After TransactionIdleSeconds without a request, the service rolls it backOtherwise a client that has disappeared would hold the connection and locks forever
At most MaxTransactionsPerCaller open per caller (429)Each one holds a pooled connection
When the service shuts down, open transactions are rolled back—

Tables without a key: actions

{ "Name": "fachdaten", "Provider": "sqlserver", "ConnectionString": "…",
  "KeylessWriteTables": [ "dbo.Protokoll" ] }

The forms of the entries are the same as for RowStampTables (schema.tabelle, schema.*, *), as is the check at startup. A listed table that does have a key after all continues to be written via that key and is named in the log.

Via the protocol, this happens with two bound actions that the schema document declares only for released tables. That's how a client recognizes whether the table is writable:

POST /odata/v4/fachdaten/dbo/Protokoll/UpdateByOriginal
{ "original": { "Nummer": 2, "Text": "zwei", "Betrag": null, "Aktiv": false },
  "changes":  { "Text": "neu" } }
 
POST /odata/v4/fachdaten/dbo/Protokoll/DeleteByOriginal
{ "original": { … } }

The action name may be qualified with the namespace of the schema (fachdaten.dbo.UpdateByOriginal), as the protocol writes it. The check is made according to what the action does: update needs the right for PATCH, delete the right for DELETE. A right only to create doesn't open this door.

RuleReason
original names every comparable column—or the row version or row stampA condition over fewer columns would hit records that match only in these values
Every offered column of limited length is comparable; long text and long binary values (max), text, ntext, image, xml aren'tThe engine can't compare text through xml with =; Access doesn't name long text—requiring it would make every change from Access fail
A missing old value becomes IS NULL= NULL is never true; a substitute value such as ISNULL(x, 0) would coincide with real zeros and would also hit the row with 0
Exactly one record is changed; if the old values match several, the change is rolled back and rejected with 409Several matches are records that are equal in all comparable columns; they may differ in long text or in columns that aren't offered. Access directly against SQL Server reports error 3362 in this case and rolls the change back; the service behaves the same way
Nothing matched: 412The record was changed or deleted; without a key, the two can't be told apart

Response on success: 200 with {"value": 1}.

Grouping and aggregating ($apply)

A set can be read grouped and aggregated, according to OData Data Aggregation 4.0. The reason is Access: the database engine sends SUM, COUNT(column), MIN, MAX, GROUP BY, HAVING, DISTINCT and, for a standard deviation, SUM(x * x) over a linked table to the ODBC driver, regardless of what the driver reports about itself, and doesn't calculate on its own if the driver refuses—the query then fails with “ODBC–call failed.” Without pass-through, that is, for every key without the sql right, $apply and—where the alias accepts them—checked statements lead to sums and groups. It's a read: the read rights of the set apply, and a sum over a million rows travels as one number.

GET …/Auftrag?$apply=groupby((Werk),aggregate($count as Anzahl,Menge with sum as Summe))
GET …/Auftrag?$apply=filter(Werk eq 3)/aggregate(Menge with average as Mittel)
GET …/Rolle?$apply=groupby((Abteilung),aggregate($count as N))&$filter=N gt 1&$orderby=N desc&$top=10
GET …/Rolle?$apply=compute(Id mul Id as Q)/aggregate(Q with sum as Quadratsumme)
GET …/Rolle?$apply=groupby((Abteilung))                          distinct values (DISTINCT)
{"@odata.context":"…/$metadata#Auftrag(Werk,Anzahl,Summe)",
 "value":[{"Werk":1,"Anzahl":2140,"Summe":18350.5}, …]}

What $apply runs—in this order, each transformation at most once:

TransformationMeaningSQL
filter(Bedingung)rows before grouping; grammar as for $filterWHERE
compute(Ausdruck as Alias, …)calculating with add, sub, mul over numeric columns and numbersexpression in the aggregate
groupby((Eigenschaft, …)[,aggregate(…)])groups; without aggregate the distinct valuesGROUP BY
aggregate(…)aggregating without groups: one rowaggregate without GROUP BY
AggregateResultSQLResult type
$count as ArowsCOUNT_BIG(*)Edm.Int64
x with sum as AsumSUM; integers via bigintintegers Edm.Int64, decimal Edm.Decimal, floating point Edm.Double
x with average as AmeanAVG; integers via floatintegers and floating point Edm.Double, decimal Edm.Decimal
x with min as A, x with max as Asmallest, largest valueMIN, MAXtype of the column
x with countdistinct as Adistinct values without missing onesCOUNT_BIG(DISTINCT x)Edm.Int64
x with Wkl.Store.V1.count as Avalues that aren't missingCOUNT_BIG(x)Edm.Int64

After $apply, the other options refer to the groups, as the protocol intends: $filter is SQL HAVING and may name an alias ($filter=N gt 1), $orderby orders the groups, $top, $skip and $count=true window and count them. They name only what the result has—a grouping property or an alias; the service rejects another column of the set with 400. Without $orderby, the groups are ordered by their grouping properties, so that consecutive windows don't overlap.

DefinitionReason
Sum and mean of integers are first converted per column to bigint or floatSQL Server sums int as int and aborts beyond 2^31 with an overflow; it calculates the mean of integers without a remainder (the mean of 1 and 2 is 1). The protocol's sum is as large as it is, its mean has decimal places. Per column, not on the result, so that x mul x doesn't overflow beforehand
div and mod aren't available in computeSQL divides integers without a remainder, OData doesn't—one operator would give two answers
Wkl.Store.V1.count instead of a standard methodThe protocol has no method that counts values without missing ones (COUNT(column)); $count counts rows, countdistinct distinct values. An own method must be named with a namespace; the schema document announces it with Aggregation.CustomAggregationMethods
An alias may not have the same name as a property of the set, stored or encodedOtherwise the result would carry two values under one name, and a condition on the groups wouldn't know which one it means
xml, text, ntext, image aren't grouped, compared or counted as distinct; text isn't summed; min/max not over yes/no; the computed row stamp not at allSQL Server doesn't compare these types or refuses the function; the rejection names the reason instead of passing on a database error
Without $top, a page delivers up to MaxTop groups, not just DefaultTopEvery further page groups all rows in the database again before it can jump to the page; the groups themselves are few and small. One page instead of several avoids this repetition
The service rejects $select alongside $apply, and $skiptoken for a grouped resultThe result has the properties that $apply names; a group has no key by which it could be continued—paging uses $skip
Any other transformation (concat, topcount, rollup …), method or position is rejected with 400, never skippedA skipped transformation answers a different question with a result that looks like the answer

The schema document announces what is run: Aggregation.ApplySupported with the four transformations and without rollup, plus the custom counting method. The ODBC driver groups only via a service that announces this. On the native route (/api/v1/cf/…), $apply remains rejected.

The statement is a derived table from which the groups are read like rows:

SELECT TOP (100) * FROM (
    SELECT [Kunde], COUNT_BIG(*) AS [Anzahl] FROM [dbo].[Umsatz] WHERE [Nr] > @p0 GROUP BY [Kunde]
) AS [gw_g] WHERE [Anzahl] > @p1 ORDER BY [Anzahl] DESC

Only this way can the condition on the groups name an alias, and the window follows the same logic as in an ordinary read. Values go in as parameters, as everywhere; numbers from compute are written by the service from the value it has read, never from the caller's text.

See also

en/wiki/data-conectivity/database-gateway/reference/odata.txt · Last modified: by Wolfgang van der Stille