Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/28125-driver-odata-v2-dialect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/driver-odata': minor
'@objectstack/spec': minor
---

feat(driver-odata): an OData v2 service is served by the same driver with `version: '2.0'` — the verbose JSON envelope, `$inlinecount`, typed literals, `MERGE`, and `$metadata` Associations read into the lookups an import drafts

Clause-②: yes (widening)
Metadata-protocol: added

`createODataDriverPlugin({ …, version: '2.0' })` (and `parseODataDriverConfig`) now accepts `'2.0'` beside `'4.0'`, which stays the default. Before, any value but `'4.0'` refused the boot. The v2 dialect (`odataV2Dialect`, `parseCsdlV2`, exported beside the v4 ones) plugs into the existing dialect seam; the driver and the `$filter` translator are unchanged, so the never-narrower rule, paging, keys, ETags and the error mapping hold as on v4. Only the public v2 protocol ships: SAP Gateway's CSRF token handshake, `sap-client` and the `sap:*` annotations stay out of this repository, for a vendor plugin.

- **Requests and the envelope.** Requests carry `DataServiceVersion: 2.0` and ask for `application/json`. A collection is read out of `d.results` (or a 1.0 service's bare `d` array), with `__next` followed as `@odata.nextLink` is and the string `__count` read as the count. An entity is read out of `d`; its ETag comes from `__metadata.etag`; `__metadata` and each navigation's `{ __deferred }` stub are dropped from the row.
- **Count.** `count` sends `$inlinecount=allpages&$top=0`.
- **`$filter`.** `$contains` is `substringof('x',P)`; `startswith` / `endswith` / `tolower` as on v4. Literals are typed: `datetime'…'`, `datetimeoffset'…'`, `time'PT…'`, `guid'…'`, `7L`, `1.5M` (no exponent), `1.5d`, `1.5f`. An `Edm.DateTime` comparand is pushed down only when it names its instant (a zone, an offset or a `Date`), as that instant's UTC wall clock; a bare date or a zone-less date-time is left to the engine.
- **Keys.** Key predicates take the same typed literals (`Shipments(guid'…')`, `Items(7L)`), and a composite key's `id` (`TrackingId=guid'…',Leg=2L`) is read back into the same predicate.
- **Values.** `Edm.DateTime` (`/Date(ms)/`) is read as the UTC instant, or as its date under a field the object declares `date` (v2 has no `Edm.Date`); `Edm.Int64` and `Edm.Decimal` text is read as a number (an `Edm.Int64` beyond the safe range stays text); `Edm.Time` (`PT13H20M`) is read as the time of day. Writes go out in the same forms.
- **Writes.** `create` is `POST`; a partial update is `MERGE`, sent as a `POST` tunnelled with `X-HTTP-Method: MERGE`; `delete` is `DELETE`. `If-Match` carries the `__metadata.etag` of the row's last read.
- **`$metadata`.** EDMX 1.0 is read: a navigation's target and cardinality come from its `Association` ends, its foreign key from the Association's `ReferentialConstraint` when this type is the dependent end, and its target entity set from the `AssociationSet`s. `introspectSchema()` reports dialect `odata-2.0` and the same navigation shape v4 yields, so `generateObjectDraft` / `importObject` draft the same lookups over either version. A v2 document under `version: '4.0'`, or a v4 one under `'2.0'`, is refused with the version that reads it named.
- **The dialect seam** gains four optional members (`readValue`, `writeValue`, `keyValue`, `updateRequest`); a dialect that leaves one out keeps the v4 behaviour, so an out-of-repo dialect still compiles. `readValue` is given the declared type of the field a property backs, which the driver now records from the object's `fields` in `registerExternalObject`.
- **`@objectstack/spec`:** the type matrix's `odata` arm reads the two primitives only v2's CSDL declares, `Edm.DateTime` → `datetime` and `Edm.Time` → `time`. Both were `unknown`: a draft typed them `text` and flagged them for review, and a `datetime` or `time` field declared over one failed Gate 2 as a `type_mismatch`. Nothing that passed before fails now.
80 changes: 66 additions & 14 deletions content/docs/data-modeling/odata-datasources.mdx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
---
title: "OData datasources — ERP records, live, with no driver of your own"
navTitle: OData Datasources
description: Declare a datasource over an OData v4 service such as SAP S/4HANA or Dynamics 365, bind objects to its entity sets, and read and write them live, with no code per entity set.
description: Declare a datasource over an OData v4 or v2 service such as SAP S/4HANA, SAP Gateway or Dynamics 365, bind objects to its entity sets, and read and write them live, with no code per entity set.
---

Many business systems already publish their records over **OData v4**: SAP S/4HANA,
Microsoft Dynamics 365 and Dataverse, and any service that serves a CSDL 4.0
`$metadata` document. For those you need no driver of your own. Mount
`$metadata` document. Many others still speak **OData v2**: most SAP Gateway services,
the older Dynamics CRM `OrganizationData.svc` and SharePoint 2013 REST. For those you
need no driver of your own. Mount
`@objectstack/driver-odata` once per service, declare a datasource, and bind an object
to each entity set you want. Every view, form, permission set, formula and hook then
works on those records, and no row is stored on the platform side.
Expand All @@ -20,16 +22,17 @@ says when to choose it over a driver of your own or a synced copy.

Everything on this page is what the driver's own tests assert, against an in-process
OData service and behind the real engine
(`packages/drivers/driver-odata/src/odata-driver.floor.test.ts` and
`odata-driver.conformance.test.ts`). Read them when this page and the runtime seem to
disagree.
(`packages/drivers/driver-odata/src/odata-driver.floor.test.ts`,
`odata-driver.conformance.test.ts`, which runs over a v4 and a v2 service, and
`odata-driver-v2.test.ts`). Read them when this page and the runtime seem to disagree.

## Check the service first

- **It speaks OData v4.** Open `<serviceRoot>$metadata`. The root element must say
`Version="4.0"`. Only the v4 dialect ships. A service that speaks only OData v2 needs a
v2 dialect, which this package does not include. Many older SAP Gateway services are
v2 only, so check each service you plan to bind.
- **Which OData version it speaks.** Open `<serviceRoot>$metadata`. A root element
that says `Version="4.0"` is a v4 service, the default. One that says `Version="1.0"`
(EDMX 1.0, with `m:DataServiceVersion="2.0"` or `"1.0"` below it) is a v2 service:
declare `version: '2.0'` ([OData v2 services](#odata-v2-services)). A document of the
other version is refused at the first request, with the version that reads it named.
- **Each entity set you bind has a key.** The key becomes the record's `id`. An entity
type with no usable key is refused at the first request.
- **You know how it authenticates.** The driver sends no credential, a bearer token, or
Expand Down Expand Up @@ -61,15 +64,15 @@ export default defineStack({
| :--- | :--- | :--- |
| `name` | `odata` | The datasource it serves. It is also the driver name and the `driver.<name>` service suffix. |
| `serviceRoot` | (required) | The service root URL, http or https, with no credentials and no query string. |
| `version` | `4.0` | The protocol version. `4.0` is the only value. |
| `version` | `4.0` | The protocol version: `4.0` or `2.0` ([OData v2 services](#odata-v2-services)). |
| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. Leave it `none` when the datasource borrows a connector instance's credential ([Credentials](#3-credentials)). |
| `secrets` | (none) | Where `credentialsRef` is resolved: an object with `resolve(credentialsRef)`. When absent, the host's `datasource-secret-resolver` service is used; `os serve` registers one. |
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). |
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). A v2 service has no such preference and pages by its own size. |
| `requestTimeoutMs` | `30000` | The timeout of each request. |

The options are checked when the plugin is built. An unknown key, a `serviceRoot` with
a user name or a query in it, or a version other than `4.0` refuses the boot with every
problem named.
a user name or a query in it, or a version other than `4.0` or `2.0` refuses the boot
with every problem named.

## 2. Declare the datasource and bind objects

Expand Down Expand Up @@ -324,6 +327,54 @@ and the ways out are the same as on any plugin driver
- **Editable data:** declare `sharingModel: 'public_read_write'`, and decide who may edit
in a `beforeUpdate` hook. A member's delete is still refused on this model.

## OData v2 services

Declare the version on the plugin; nothing else in the datasource or the objects changes:

```typescript
createODataDriverPlugin({
name: 'gateway',
serviceRoot: 'https://erp.example.com/sap/opu/odata/sap/ZSALES_SRV/',
version: '2.0',
auth: { scheme: 'basic', username: 'SVC_PLATFORM' },
});
```

The driver then speaks the public v2 protocol. Everything above holds, spelled the v2 way:

| | v4 | v2 |
| :--- | :--- | :--- |
| Request headers | `OData-Version: 4.0` | `DataServiceVersion: 2.0`, `Accept: application/json` (verbose JSON) |
| A collection | `value`, `@odata.nextLink`, `@odata.count` | `d.results`, `__next`, `__count` (a 1.0 service's bare `d` array is read too) |
| The count | `$count=true` | `$inlinecount=allpages` |
| `$contains` | `contains(P,'x')` | `substringof('x',P)` |
| Literals | `2024-03-01T10:00:00Z`, a bare GUID, `1.5` | `datetime'…'`, `datetimeoffset'…'`, `time'PT13H20M0S'`, `guid'…'`, `7L`, `1.5M`, `1.5d`, `1.5f` |
| Keys | `Orders(4711)` | the same typed literals: `Shipments(guid'…')`, `Items(7L)` |
| A partial update | `PATCH` | `POST` with `X-HTTP-Method: MERGE` |
| The ETag | `@odata.etag` | `__metadata.etag` |
| `$metadata` | CSDL 4.0, navigation with its `ReferentialConstraint` | EDMX 1.0: `Association` / `AssociationSet`, the constraint on the Association |

- **Values are read the way the engine reads them.** `Edm.DateTime` arrives as
`/Date(<ms>)/` and is read as that UTC instant (`1996-07-04T00:00:00.000Z`).
`Edm.Int64` and `Edm.Decimal` arrive as text and are read as numbers, except an
`Edm.Int64` beyond JavaScript's safe range, which stays text. `Edm.Time` is a duration
since midnight (`PT13H20M`) and is read as the time of day (`13:20:00`). Writes go out in
the same forms.
- **v2 has no `Edm.Date`.** A v2 service keeps a date in an `Edm.DateTime` at midnight.
Declare the field `date`, and the driver reads the date alone (`1996-07-04`).
- **A zone-less date comparand is left to the engine.** A filter on an `Edm.DateTime`
is pushed down only when its value names its instant (`…Z`, an offset, or a `Date`).
A bare date or a date-time without a zone is applied by the engine instead, so the
service is never asked a narrower question.
- **The import reads Associations.** An imported `Edm.DateTime` becomes a `datetime`
field and an `Edm.Time` a `time` field. A navigation whose Association carries a
`ReferentialConstraint`, with this entity type on its dependent end, becomes the same
`lookup` a v4 navigation does.
- **What stays out of this package.** Only the public v2 protocol ships here. Vendor
mechanics stay out: SAP Gateway's CSRF token handshake, the `sap-client` parameter and
the `sap:*` annotations, for example. A service that needs one is served by a vendor
plugin.

## When the service fails

| What happened | The caller is answered |
Expand All @@ -340,7 +391,8 @@ as a sharing scope. The full mapping is in

## What it does not do

- **OData v2.** Only the v4 dialect ships.
- **Vendor-specific mechanics** on a v2 service, such as SAP's CSRF token handshake or
`sap-client` ([OData v2 services](#odata-v2-services)).
- **`$expand` and navigation properties.** Bind each related entity set as an object of
its own; an imported foreign key becomes a `lookup` onto it.
- **Actions and functions** of the service. `execute` is refused.
Expand Down
9 changes: 5 additions & 4 deletions packages/drivers/driver-odata/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# @objectstack/driver-odata

An OData v4 datasource driver for ObjectStack. Declare a datasource over an OData
service (SAP S/4HANA, Dynamics 365, Dataverse, any CSDL 4.0 service), bind objects to
An OData v4 and v2 datasource driver for ObjectStack. Declare a datasource over an OData
service (SAP S/4HANA, Dynamics 365, Dataverse, any CSDL 4.0 service; with `version: '2.0'`,
SAP Gateway and any other EDMX 1.0 service), bind objects to
its entity sets, and every view, form, permission and hook works on them, live. No row
is stored on the platform side, and no code is written per entity set.

Expand Down Expand Up @@ -66,10 +67,10 @@ export default defineStack({
| :--- | :--- | :--- |
| `name` | `odata` | The datasource name, the driver name and its `driver.<name>` service suffix. |
| `serviceRoot` | — | The service root URL (http or https, no credentials, no query). |
| `version` | `4.0` | The protocol version. Only the v4 dialect ships. |
| `version` | `4.0` | The protocol version: `4.0`, or `2.0` for the v2 dialect (verbose JSON, `$inlinecount`, typed literals, `MERGE`, Associations). Vendor mechanics such as SAP's CSRF handshake are not in this package. |
| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. Leave it `none` when the datasource borrows a connector instance's credential (below). |
| `secrets` | (none) | An object with `resolve(credentialsRef)` that the secret is resolved through. When absent, the kernel service `datasource-secret-resolver` is read at the first request; `os serve` registers it over its own `sys_secret` store. A host composing its own kernel passes the binder it gives `DatasourceAdminServicePlugin`, or registers that service. |
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). |
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`; a v2 service pages by its own size). |
| `requestTimeoutMs` | `30000` | Per-request timeout. |

## Borrowing a connector instance's credential
Expand Down
2 changes: 1 addition & 1 deletion packages/drivers/driver-odata/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@objectstack/driver-odata",
"version": "18.2.0",
"license": "Apache-2.0",
"description": "OData v4 datasource driver for ObjectStack — serve an OData service's entity sets as external objects through a plugin driver",
"description": "OData v4 and v2 datasource driver for ObjectStack — serve an OData service's entity sets as external objects through a plugin driver",
"keywords": [
"objectstack",
"driver",
Expand Down
Loading
Loading