From 667660add518c9a9e904cf24836ea68dfb09b5a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:41:12 +0000 Subject: [PATCH] feat(driver-odata): an OData v2 dialect beside v4, selected with version: '2.0' The v2 dialect plugs into the existing dialect seam: the verbose JSON envelope (d.results, __count, __next, __metadata), $inlinecount, the typed v2 literals and key predicates, substringof, MERGE tunnelled through POST, and EDMX 1.0 $metadata whose Associations and AssociationSets are read into the navigation shape v4 yields, so an import drafts the same lookups. Edm.DateTime, Edm.Int64, Edm.Decimal and Edm.Time values are read and written in their v2 JSON forms; a date field over an Edm.DateTime reads its date. The spec type matrix's odata arm maps the two v2-only primitives, Edm.DateTime and Edm.Time. Vendor mechanics (SAP's CSRF handshake, sap-client) stay out. The conformance kit and the eleven shared case-sets now also run over an in-process v2 service that refuses every v4 spelling. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cykf9LVyVYxnw1NL95 --- .changeset/28125-driver-odata-v2-dialect.md | 21 + .../docs/data-modeling/odata-datasources.mdx | 80 ++- packages/drivers/driver-odata/README.md | 9 +- packages/drivers/driver-odata/package.json | 2 +- .../__fixtures__/northwind-v2-metadata.xml | 244 +++++++++ .../src/__fixtures__/odata-service.ts | 192 ++++++- .../src/__fixtures__/recorded-service-v2.ts | 101 ++++ .../src/__fixtures__/recorded-service.ts | 9 +- packages/drivers/driver-odata/src/config.ts | 6 +- .../driver-odata/src/dialect-v2.test.ts | 226 ++++++++ .../drivers/driver-odata/src/dialect-v2.ts | 500 ++++++++++++++++++ .../drivers/driver-odata/src/dialect-v4.ts | 44 +- packages/drivers/driver-odata/src/dialect.ts | 28 + packages/drivers/driver-odata/src/index.ts | 3 +- .../driver-odata/src/odata-driver-v2.test.ts | 232 ++++++++ .../src/odata-driver.conformance-kit.test.ts | 43 +- .../src/odata-driver.conformance.test.ts | 466 ++++++++-------- .../drivers/driver-odata/src/odata-driver.ts | 53 +- .../__fixtures__/northwind-v2-metadata.xml | 244 +++++++++ ...ternal-object-draft-odata-metadata.test.ts | 86 +++ packages/spec/src/data/type-compat.test.ts | 5 + packages/spec/src/data/type-compat.ts | 6 +- 22 files changed, 2291 insertions(+), 309 deletions(-) create mode 100644 .changeset/28125-driver-odata-v2-dialect.md create mode 100644 packages/drivers/driver-odata/src/__fixtures__/northwind-v2-metadata.xml create mode 100644 packages/drivers/driver-odata/src/__fixtures__/recorded-service-v2.ts create mode 100644 packages/drivers/driver-odata/src/dialect-v2.test.ts create mode 100644 packages/drivers/driver-odata/src/dialect-v2.ts create mode 100644 packages/drivers/driver-odata/src/odata-driver-v2.test.ts create mode 100644 packages/services/service-datasource/src/__tests__/__fixtures__/northwind-v2-metadata.xml diff --git a/.changeset/28125-driver-odata-v2-dialect.md b/.changeset/28125-driver-odata-v2-dialect.md new file mode 100644 index 00000000000..18362d5b451 --- /dev/null +++ b/.changeset/28125-driver-odata-v2-dialect.md @@ -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. diff --git a/content/docs/data-modeling/odata-datasources.mdx b/content/docs/data-modeling/odata-datasources.mdx index 1e34ad3bfa0..74eaa5acfff 100644 --- a/content/docs/data-modeling/odata-datasources.mdx +++ b/content/docs/data-modeling/odata-datasources.mdx @@ -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. @@ -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 `$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 `$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 @@ -61,15 +64,15 @@ export default defineStack({ | :--- | :--- | :--- | | `name` | `odata` | The datasource it serves. It is also the driver name and the `driver.` 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 @@ -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()/` 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 | @@ -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. diff --git a/packages/drivers/driver-odata/README.md b/packages/drivers/driver-odata/README.md index a060365cae9..7ef4affd1ab 100644 --- a/packages/drivers/driver-odata/README.md +++ b/packages/drivers/driver-odata/README.md @@ -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. @@ -66,10 +67,10 @@ export default defineStack({ | :--- | :--- | :--- | | `name` | `odata` | The datasource name, the driver name and its `driver.` 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 diff --git a/packages/drivers/driver-odata/package.json b/packages/drivers/driver-odata/package.json index 8073ed8acd3..3758d20808c 100644 --- a/packages/drivers/driver-odata/package.json +++ b/packages/drivers/driver-odata/package.json @@ -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", diff --git a/packages/drivers/driver-odata/src/__fixtures__/northwind-v2-metadata.xml b/packages/drivers/driver-odata/src/__fixtures__/northwind-v2-metadata.xml new file mode 100644 index 00000000000..654c45791d9 --- /dev/null +++ b/packages/drivers/driver-odata/src/__fixtures__/northwind-v2-metadata.xml @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/drivers/driver-odata/src/__fixtures__/odata-service.ts b/packages/drivers/driver-odata/src/__fixtures__/odata-service.ts index 08d0171043e..151c35bfffe 100644 --- a/packages/drivers/driver-odata/src/__fixtures__/odata-service.ts +++ b/packages/drivers/driver-odata/src/__fixtures__/odata-service.ts @@ -1,11 +1,21 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * A small in-process OData v4 service for the conformance suite: one entity + * A small in-process OData service for the conformance suite: one entity * set over rows held in memory, answering `$metadata`, collection reads with * `$filter` / `$orderby` / `$skip` / `$top` / `$count`, server-driven paging * through `@odata.nextLink`, and `POST` / `PATCH` / `DELETE` by key. * + * With `version: '2.0'` it speaks OData v2 instead, as strictly as a v2 + * service parses: an EDMX 1.0 `$metadata`, the verbose JSON envelope + * (`d.results`, `__count`, `__next`, `__metadata`; `Edm.DateTime` written + * `/Date(ms)/`, `Edm.Time` as a duration), `$inlinecount` (an unknown `$` + * option such as v4's `$count` is refused), `substringof` (v4's `contains` is + * refused), the typed literals (`datetime'…'`, `time'…'`, `guid'…'`, `20M`, + * `1.5d`; a bare literal against a property that needs a typed one is + * refused), and `MERGE` tunnelled through `POST` (`PATCH` is refused). Rows + * are held in the engine's spelling either way. + * * Its `$filter` evaluator reads the subset of the grammar the driver's * translator writes (comparisons, `and` / `or` / `not`, parentheses, * `contains` / `startswith` / `endswith` / `tolower`, `true` / `false` / @@ -23,7 +33,12 @@ import type { FetchLike } from '../transport.js'; -export type EdmType = 'Edm.String' | 'Edm.Int32' | 'Edm.Double' | 'Edm.Boolean' | 'Edm.Date' | 'Edm.DateTimeOffset' | 'Edm.TimeOfDay'; +export type EdmType = + | 'Edm.String' | 'Edm.Int32' | 'Edm.Double' | 'Edm.Boolean' | 'Edm.Date' | 'Edm.DateTimeOffset' | 'Edm.TimeOfDay' + // OData v2 only. + | 'Edm.DateTime' | 'Edm.Time'; + +export type ODataVersion = '4.0' | '2.0'; export interface ServiceProperty { name: string; @@ -39,16 +54,27 @@ export interface ODataServiceOptions { collation?: 'ordinal' | 'case-insensitive'; /** Rows per server page; a longer answer carries `@odata.nextLink`. */ serverPageSize?: number; + /** The protocol the service speaks; `4.0` when absent. */ + version?: ODataVersion; } export const CONFORMANCE_ROOT = 'https://odata.conformance.test/svc/'; const NS = 'Conformance'; -export function metadataFor(entitySet: string, properties: readonly ServiceProperty[]): string { +export function metadataFor(entitySet: string, properties: readonly ServiceProperty[], version: ODataVersion = '4.0'): string { const props = properties .map((p, i) => ``) .join(''); + if (version === '2.0') { + return ` + + + +${props} + +`; + } return ` @@ -101,12 +127,24 @@ type Node = | { kind: 'compare'; op: string; left: Node; right: Node } | { kind: 'call'; fn: string; args: Node[] } | { kind: 'property'; name: string } - | { kind: 'literal'; text: string; quoted: boolean }; + | { kind: 'literal'; text: string; quoted: boolean; typed?: TypedPrefix }; const COMPARISON = new Set(['eq', 'ne', 'gt', 'ge', 'lt', 'le']); const FUNCTIONS = new Set(['contains', 'startswith', 'endswith', 'tolower']); +const V2_FUNCTIONS = new Set(['substringof', 'startswith', 'endswith', 'tolower']); -function parse(text: string, types: ReadonlyMap): Node { +/** v2's prefixed literals (`datetime'…'`). */ +type TypedPrefix = 'datetime' | 'datetimeoffset' | 'time' | 'guid'; +const TYPED_PREFIXES: ReadonlySet = new Set(['datetime', 'datetimeoffset', 'time', 'guid']); +/** The prefix a v2 literal of each type must carry; a bare literal against one is refused. */ +const V2_PREFIX_OF: Partial> = { + 'Edm.DateTime': 'datetime', 'Edm.DateTimeOffset': 'datetimeoffset', 'Edm.Time': 'time', +}; +/** A v2 numeric literal with its type suffix (`7L`, `20M`, `1.5d`, `2f`). */ +const V2_SUFFIXED_NUMBER = /^(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)[LlMmDdFf]$/; + +function parse(text: string, types: ReadonlyMap, version: ODataVersion = '4.0'): Node { + const functions = version === '2.0' ? V2_FUNCTIONS : FUNCTIONS; const tokens = tokenize(text); let pos = 0; const peek = () => tokens[pos]; @@ -163,7 +201,10 @@ function parse(text: string, types: ReadonlyMap): Node { } if (t.kind === 'string') return { kind: 'literal', text: t.value, quoted: true }; if (t.kind === 'punct') throw new FilterSyntaxError(`unexpected '${t.value}'`); - if (FUNCTIONS.has(t.value) && peek()?.value === '(') { + if (version === '2.0' && TYPED_PREFIXES.has(t.value) && peek()?.kind === 'string') { + return { kind: 'literal', text: (tokens[pos++] as { value: string }).value, quoted: false, typed: t.value as TypedPrefix }; + } + if (functions.has(t.value) && peek()?.value === '(') { pos++; const args: Node[] = [orExpr()]; while (peek()?.value === ',') { pos++; args.push(orExpr()); } @@ -179,9 +220,42 @@ function parse(text: string, types: ReadonlyMap): Node { const tree = orExpr(); if (pos !== tokens.length) throw new FilterSyntaxError('trailing tokens'); + if (version === '2.0') checkV2Literals(tree, types); return tree; } +/** + * A v2 parser types each literal by its spelling and refuses a comparison of + * incompatible types: `At eq 2024-01-01T00:00:00Z` (no `datetime` prefix) + * and `N lt 1.5` against an `Edm.Int32` (a Double literal) are refused, the + * way a v2 service answers them. + */ +function checkV2Literals(node: Node, types: ReadonlyMap): void { + switch (node.kind) { + case 'and': case 'or': checkV2Literals(node.left, types); checkV2Literals(node.right, types); return; + case 'not': checkV2Literals(node.operand, types); return; + case 'call': node.args.forEach((a) => checkV2Literals(a, types)); return; + case 'compare': { + const property = node.left.kind === 'property' ? node.left : node.right.kind === 'property' ? node.right : undefined; + const literal = node.left.kind === 'literal' ? node.left : node.right.kind === 'literal' ? node.right : undefined; + if (!property || !literal || (!literal.quoted && !literal.typed && literal.text === 'null')) return; + const type = types.get(property.name); + const prefix = type ? V2_PREFIX_OF[type] : undefined; + if (prefix !== undefined && literal.typed !== prefix) { + throw new FilterSyntaxError(`a ${type} property is compared with a literal that is not ${prefix}'…'`); + } + if (prefix === undefined && literal.typed !== undefined && literal.typed !== 'guid') { + throw new FilterSyntaxError(`a ${literal.typed}'…' literal is compared with a ${type} property`); + } + if (type === 'Edm.Int32' && !literal.quoted && /[.eEdDfFmM]/.test(literal.text)) { + throw new FilterSyntaxError(`a non-integer literal '${literal.text}' is compared with an Edm.Int32 property`); + } + return; + } + default: return; + } +} + type Value = string | number | boolean | null; interface EvalContext { @@ -195,6 +269,22 @@ function comparable(value: unknown, type: EdmType | undefined): Value { if (value === null || value === undefined) return null; switch (type) { case 'Edm.DateTimeOffset': return Date.parse(String(value)); + // v2: held as an ISO instant; a `datetime'…'` body is the UTC wall clock, without a zone. + case 'Edm.DateTime': { + const text = String(value); + if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return Date.parse(`${text}T00:00:00Z`); + return Date.parse(/(Z|[+-]\d{2}:\d{2})$/.test(text) ? text : `${text}Z`); + } + case 'Edm.Time': { + const text = String(value); + const duration = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?$/.exec(text); + if (duration) { + const [, h = '0', m = '0', s = '0'] = duration; + return (Number(h) * 3600 + Number(m) * 60 + Number(s)) * 1000; + } + const [h, m, s = '0'] = text.split(':'); + return (Number(h) * 3600 + Number(m) * 60 + Number(s)) * 1000; + } case 'Edm.TimeOfDay': { const [h, m, s = '0'] = String(value).split(':'); return (Number(h) * 3600 + Number(m) * 60 + Number(s)) * 1000; @@ -213,20 +303,21 @@ function evaluate(node: Node, ctx: EvalContext): unknown { case 'not': return evaluate(node.operand, ctx) !== true; case 'property': return ctx.row[node.name] ?? null; case 'literal': { - if (node.quoted) return node.text; + if (node.quoted || node.typed) return node.text; if (node.text === 'null') return null; if (node.text === 'true') return true; if (node.text === 'false') return false; - return node.text; + return V2_SUFFIXED_NUMBER.exec(node.text)?.[1] ?? node.text; } case 'call': { const args = node.args.map((a) => evaluate(a, ctx)); if (node.fn === 'tolower') return typeof args[0] === 'string' ? args[0].toLowerCase() : null; - const [subject, needle] = args; + // v2's `substringof` takes the needle first. + const [subject, needle] = node.fn === 'substringof' ? [args[1], args[0]] : args; if (typeof subject !== 'string' || typeof needle !== 'string') return false; const s = ctx.fold(subject); const n = ctx.fold(needle); - if (node.fn === 'contains') return s.includes(n); + if (node.fn === 'contains' || node.fn === 'substringof') return s.includes(n); if (node.fn === 'startswith') return s.startsWith(n); return s.endsWith(n); } @@ -262,6 +353,11 @@ function typeOfSide(node: Node, ctx: EvalContext): EdmType | undefined { // The service // --------------------------------------------------------------------------- +/** The system query options a v2 service reads; any other `$` option is refused. */ +const V2_QUERY_OPTIONS: ReadonlySet = new Set([ + '$filter', '$orderby', '$top', '$skip', '$select', '$expand', '$inlinecount', '$format', '$skiptoken', +]); + export interface ODataService { fetch: FetchLike; /** Every `$filter` the service was sent, in order. */ @@ -271,19 +367,50 @@ export interface ODataService { export function odataService(options: ODataServiceOptions): ODataService { const { entitySet, properties } = options; + const v2 = options.version === '2.0'; const key = properties[0]!.name; const types = new Map(properties.map((p) => [p.name, p.type] as const)); const fold = options.collation === 'case-insensitive' ? (t: string) => t.toLowerCase() : (t: string) => t; const pageSize = options.serverPageSize ?? 1000; const rows = options.rows.map((r) => ({ ...r })); const filters: string[] = []; - const metadata = metadataFor(entitySet, properties); + const metadata = metadataFor(entitySet, properties, options.version); const json = (status: number, body?: unknown) => new Response(body === undefined ? null : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); - const refused = (status: number, message: string) => json(status, { error: { code: String(status), message } }); - const project = (row: Record) => - Object.fromEntries(properties.map((p) => [p.name, row[p.name] ?? null])); + const refused = (status: number, message: string) => + json(status, { error: { code: String(status), message: v2 ? { lang: 'en-US', value: message } : message } }); + const project = (row: Record) => { + const entity = Object.fromEntries(properties.map((p) => [p.name, toWire(row[p.name] ?? null, p.type)])); + return v2 ? { __metadata: { uri: `${CONFORMANCE_ROOT}${entitySet}('${String(row[key])}')`, type: `${NS}.Row` }, ...entity } : entity; + }; + /** A held value as the service writes it: v2's `/Date(ms)/` and durations; v4's values as held. */ + const toWire = (value: unknown, type: EdmType): unknown => { + if (!v2 || value === null || typeof value !== 'string') return value; + if (type === 'Edm.DateTime') return `/Date(${comparable(value, type)})/`; + if (type === 'Edm.Time') { + const [h = '0', m = '0', sec = '0'] = value.split(':'); + return `PT${Number(h)}H${Number(m)}M${Number(sec)}S`; + } + return value; + }; + /** A written value as the service holds it (the inverse of {@link toWire}). */ + const fromWire = (value: unknown, type: EdmType): unknown => { + if (!v2 || typeof value !== 'string') return value; + const date = /^\/Date\((-?\d+)\)\/$/.exec(value); + if (type === 'Edm.DateTime' && date) return new Date(Number(date[1])).toISOString(); + if (type === 'Edm.Time' && value.startsWith('PT')) { + const ms = comparable(value, type) as number; + const two = (n: number) => String(Math.floor(n)).padStart(2, '0'); + return `${two(ms / 3_600_000)}:${two((ms / 60_000) % 60)}:${two((ms / 1000) % 60)}`; + } + return value; + }; + const written = (body: unknown): Record => { + const parsed = JSON.parse(String(body)) as Record; + return Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, fromWire(v, types.get(k) ?? 'Edm.String')])); + }; + const entityBody = (row: Record) => (v2 ? { d: project(row) } : project(row)); const keyOf = (segment: string): string | undefined => { const m = new RegExp(`^${entitySet}\\('((?:[^']|'')*)'\\)$`).exec(segment); return m ? m[1]!.replace(/''/g, "'") : undefined; @@ -297,12 +424,16 @@ export function odataService(options: ODataServiceOptions): ODataService { if (path === entitySet && method === 'GET') { let matched = rows; + if (v2) { + const unknown = [...url.searchParams.keys()].find((k) => k.startsWith('$') && !V2_QUERY_OPTIONS.has(k)); + if (unknown) return refused(400, `The query parameter '${unknown}' begins with a system-reserved '$' character but is not recognized.`); + } const filter = url.searchParams.get('$filter'); if (filter !== null) { filters.push(filter); let tree: Node; try { - tree = parse(filter, types); + tree = parse(filter, types, options.version); } catch (error) { return refused(400, `The query specified in the URI is not valid: ${(error as Error).message}`); } @@ -334,29 +465,44 @@ export function odataService(options: ODataServiceOptions): ODataService { const top = url.searchParams.has('$top') ? Number(url.searchParams.get('$top')) : undefined; const selected = matched.slice(skip, top === undefined ? undefined : skip + top); const pageRows = selected.slice(0, pageSize); - const body: Record = { value: pageRows.map(project) }; - if (url.searchParams.get('$count') === 'true') body['@odata.count'] = count; - if (selected.length > pageSize) { + const nextLink = () => { const next = new URL(url); next.searchParams.set('$skip', String(skip + pageSize)); if (top !== undefined) next.searchParams.set('$top', String(top - pageSize)); - body['@odata.nextLink'] = next.toString(); + return next.toString(); + }; + if (v2) { + const d: Record = { results: pageRows.map(project) }; + if (url.searchParams.get('$inlinecount') === 'allpages') d.__count = String(count); + if (selected.length > pageSize) d.__next = nextLink(); + return json(200, { d }); } + const body: Record = { value: pageRows.map(project) }; + if (url.searchParams.get('$count') === 'true') body['@odata.count'] = count; + if (selected.length > pageSize) body['@odata.nextLink'] = nextLink(); return json(200, body); } if (path === entitySet && method === 'POST') { - const created = JSON.parse(String(init.body)) as Record; + const created = written(init.body); if (created[key] === undefined) return refused(400, `The key property '${key}' is required.`); if (rows.some((r) => r[key] === created[key])) return refused(409, 'An entity with this key already exists.'); rows.push(created); - return json(201, project(created)); + return json(201, entityBody(created)); } const id = keyOf(path); const index = id === undefined ? -1 : rows.findIndex((r) => String(r[key]) === id); - if (id !== undefined && method === 'GET') return index < 0 ? refused(404, 'Not found.') : json(200, project(rows[index]!)); - if (id !== undefined && method === 'PATCH') { + if (id !== undefined && method === 'GET') return index < 0 ? refused(404, 'Not found.') : json(200, entityBody(rows[index]!)); + // v2 has no PATCH: its partial update is MERGE, tunnelled through POST. + const tunnelled = new Headers(init.headers).get('X-HTTP-Method'); + if (id !== undefined && v2 && method === 'PATCH') return refused(405, 'PATCH is not a verb of OData 2.0.'); + if (id !== undefined && v2 && method === 'POST' && tunnelled === 'MERGE') { + if (index < 0) return refused(404, 'Not found.'); + Object.assign(rows[index]!, written(init.body)); + return new Response(null, { status: 204 }); + } + if (id !== undefined && !v2 && method === 'PATCH') { if (index < 0) return refused(404, 'Not found.'); Object.assign(rows[index]!, JSON.parse(String(init.body))); return json(200, project(rows[index]!)); diff --git a/packages/drivers/driver-odata/src/__fixtures__/recorded-service-v2.ts b/packages/drivers/driver-odata/src/__fixtures__/recorded-service-v2.ts new file mode 100644 index 00000000000..058709633ed --- /dev/null +++ b/packages/drivers/driver-odata/src/__fixtures__/recorded-service-v2.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Recordings of an OData v2 service shaped like the public "Northwind" v2 + * reference service (services.odata.org/V2/Northwind/Northwind.svc), for the + * v2 dialect: its EDMX 1.0 `$metadata` (`northwind-v2-metadata.xml`) and its + * verbose JSON answers — the `d` envelope, `__metadata` on every entity, a + * `{ __deferred }` stub per navigation property, `Edm.Decimal` as a string and + * `Edm.DateTime` as `/Date(ms)/`. The exchanges are served by + * {@link recordedService}, written relative to {@link NORTHWIND_V2_ROOT}. + */ + +import { readFileSync } from 'node:fs'; +import type { Exchange } from './recorded-service.js'; + +export const NORTHWIND_V2_ROOT = 'https://odata.example.test/V2/Northwind/Northwind.svc/'; + +export const NORTHWIND_V2_METADATA = readFileSync(new URL('./northwind-v2-metadata.xml', import.meta.url), 'utf8'); + +export const NORTHWIND_V2_METADATA_EXCHANGE: Exchange = { + url: '$metadata', + body: NORTHWIND_V2_METADATA, + headers: { 'Content-Type': 'application/xml;charset=utf-8', DataServiceVersion: '1.0;' }, +}; + +const deferred = (set: string, key: string, navigation: string) => + ({ __deferred: { uri: `${NORTHWIND_V2_ROOT}${set}(${key})/${navigation}` } }); + +/** One Product as the service writes it. */ +function product(id: number, name: string, supplier: number, category: number, price: string, discontinued: boolean) { + return { + __metadata: { uri: `${NORTHWIND_V2_ROOT}Products(${id})`, type: 'NorthwindModel.Product' }, + ProductID: id, ProductName: name, SupplierID: supplier, CategoryID: category, UnitPrice: price, Discontinued: discontinued, + Category: deferred('Products', String(id), 'Category'), + Order_Details: deferred('Products', String(id), 'Order_Details'), + Supplier: deferred('Products', String(id), 'Supplier'), + }; +} + +/** Northwind's first five Products (abridged to the properties the fixture's `$metadata` keeps). */ +export const PRODUCTS_V2 = [ + product(1, 'Chai', 1, 1, '18.0000', false), + product(2, 'Chang', 1, 1, '19.0000', false), + product(3, 'Aniseed Syrup', 1, 2, '10.0000', false), + product(4, "Chef Anton's Cajun Seasoning", 2, 2, '22.0000', false), + product(5, "Chef Anton's Gumbo Mix", 2, 2, '21.3500', true), +] as const; + +/** Northwind's first two Orders: `Edm.DateTime` as `/Date(ms)/`, `Edm.Decimal` as a string. */ +export const ORDERS_V2 = [ + { + __metadata: { uri: `${NORTHWIND_V2_ROOT}Orders(10248)`, type: 'NorthwindModel.Order' }, + OrderID: 10248, CustomerID: 'VINET', EmployeeID: 5, + OrderDate: '/Date(836438400000)/', ShippedDate: '/Date(837475200000)/', Freight: '32.3800', + ShipName: 'Vins et alcools Chevalier', + Employee: deferred('Orders', '10248', 'Employee'), + Order_Details: deferred('Orders', '10248', 'Order_Details'), + }, + { + __metadata: { uri: `${NORTHWIND_V2_ROOT}Orders(10249)`, type: 'NorthwindModel.Order' }, + OrderID: 10249, CustomerID: 'TOMSP', EmployeeID: 6, + OrderDate: '/Date(836524800000)/', ShippedDate: '/Date(836956800000)/', Freight: '11.6100', + ShipName: 'Toms Spezialitäten', + Employee: deferred('Orders', '10249', 'Employee'), + Order_Details: deferred('Orders', '10249', 'Order_Details'), + }, +] as const; + +export const TRACKING_ID = '6f1e2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b'; + +/** The fixture's own Shipment: a Guid key, `Edm.Int64` as a string, an ETag in `__metadata`. */ +export const SHIPMENT_V2 = { + __metadata: { + uri: `${NORTHWIND_V2_ROOT}Shipments(guid'${TRACKING_ID}')`, + type: 'NorthwindModel.Shipment', + etag: 'W/"datetime\'2024-03-01T10%3A00%3A00\'"', + }, + TrackingId: TRACKING_ID, Sequence: '9007199254740993', Weight: 12.5, Volume: 0.75, + DispatchedAt: '2024-03-01T10:00:00Z', PickupWindow: 'PT13H20M', Label: 'Pallet 7', + Legs: deferred('Shipments', `guid'${TRACKING_ID}'`, 'Legs'), +}; + +/** The fixture's own ShipmentLeg: a composite (Guid, Int64) key. */ +export const SHIPMENT_LEG_V2 = { + __metadata: { + uri: `${NORTHWIND_V2_ROOT}ShipmentLegs(TrackingId=guid'${TRACKING_ID}',Leg=2L)`, + type: 'NorthwindModel.ShipmentLeg', + }, + TrackingId: TRACKING_ID, Leg: '2', Destination: 'Rotterdam', + Shipment: deferred('ShipmentLegs', `TrackingId=guid'${TRACKING_ID}',Leg=2L`, 'Shipment'), +}; + +/** A v2 collection answer: `{ d: { results, __count?, __next? } }`. */ +export function pageV2(rows: readonly unknown[], extra: { __count?: string; __next?: string } = {}): Record { + return { d: { results: rows, ...extra } }; +} + +/** A v2 single-entity answer: `{ d: entity }`. */ +export function entityV2(entity: unknown): Record { + return { d: entity }; +} diff --git a/packages/drivers/driver-odata/src/__fixtures__/recorded-service.ts b/packages/drivers/driver-odata/src/__fixtures__/recorded-service.ts index 87713a7d814..7c488b9766b 100644 --- a/packages/drivers/driver-odata/src/__fixtures__/recorded-service.ts +++ b/packages/drivers/driver-odata/src/__fixtures__/recorded-service.ts @@ -40,17 +40,18 @@ export interface RecordedRequest { export const METADATA_EXCHANGE: Exchange = { url: '$metadata', body: TRIPPIN_METADATA, headers: { 'Content-Type': 'application/xml' } }; -const relative = (url: string) => { +const relative = (url: string, root: string) => { const decoded = decodeURIComponent(url); - return decoded.startsWith(SERVICE_ROOT) ? decoded.slice(SERVICE_ROOT.length) : decoded; + return decoded.startsWith(root) ? decoded.slice(root.length) : decoded; }; -export function recordedService(exchanges: Exchange[]): { fetch: FetchLike; requests: RecordedRequest[] } { +/** `root`: the service root the exchanges are written relative to (TripPin's by default). */ +export function recordedService(exchanges: Exchange[], root: string = SERVICE_ROOT): { fetch: FetchLike; requests: RecordedRequest[] } { const pending = [...exchanges]; const requests: RecordedRequest[] = []; const fetch: FetchLike = async (input, init) => { const method = init.method ?? 'GET'; - const url = relative(input); + const url = relative(input, root); const headers = Object.fromEntries(Object.entries((init.headers ?? {}) as Record)); const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined; requests.push({ method, url, headers, ...(body !== undefined ? { body } : {}) }); diff --git a/packages/drivers/driver-odata/src/config.ts b/packages/drivers/driver-odata/src/config.ts index af0ee99de1f..9445eeb1f35 100644 --- a/packages/drivers/driver-odata/src/config.ts +++ b/packages/drivers/driver-odata/src/config.ts @@ -49,11 +49,11 @@ export const ODataDriverConfigSchema = z.strictObject({ }, 'serviceRoot must not carry credentials; declare external.credentialsRef on the datasource instead') .refine((value) => new URL(value).search === '', 'serviceRoot must not carry a query string') .describe('The OData service root, e.g. https://erp.example.com/odata/v4/sales/'), - version: z.literal('4.0').default('4.0') - .describe('The OData protocol version the service speaks. Only 4.0 is shipped; a v2 dialect is a new file.'), + version: z.enum(['4.0', '2.0']).default('4.0') + .describe('The OData protocol version the service speaks: 4.0 (the default) or 2.0.'), auth: AuthSchema.default({ scheme: 'none' }), pageSize: z.number().int().min(1).max(5000).default(DEFAULT_PAGE_SIZE) - .describe('Rows asked for per server page (Prefer: odata.maxpagesize).'), + .describe('Rows asked for per server page (Prefer: odata.maxpagesize). A v2 service has no such preference and pages by its own size.'), requestTimeoutMs: z.number().int().min(1).max(600_000).default(DEFAULT_REQUEST_TIMEOUT_MS) .describe('Per-request timeout in milliseconds.'), }); diff --git a/packages/drivers/driver-odata/src/dialect-v2.test.ts b/packages/drivers/driver-odata/src/dialect-v2.test.ts new file mode 100644 index 00000000000..67fd1e3780e --- /dev/null +++ b/packages/drivers/driver-odata/src/dialect-v2.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { odataV2Dialect, parseCsdlV2 } from './dialect-v2.js'; +import type { EdmProperty } from './dialect.js'; +import { TRIPPIN_METADATA } from './__fixtures__/recorded-service.js'; +import { NORTHWIND_V2_METADATA, PRODUCTS_V2, SHIPMENT_V2 } from './__fixtures__/recorded-service-v2.js'; + +const NS = 'NorthwindModel'; +const model = parseCsdlV2(NORTHWIND_V2_METADATA); +const prop = (type: string, isCollection = false): EdmProperty => ({ name: 'P', type, isCollection, nullable: true }); + +describe('EDMX 1.0 (CSDL v2) $metadata reader', () => { + it('reads entity sets across schemas, with their qualified entity type and the DataServiceVersion', () => { + expect(model.version).toBe('1.0'); + expect([...model.entitySets.keys()]).toEqual([ + 'Categories', 'Employees', 'Order_Details', 'Orders', 'Products', 'Suppliers', 'Shipments', 'ShipmentLegs', + ]); + expect(model.entitySets.get('Products')?.entityType).toBe(`${NS}.Product`); + expect(model.enumTypes.size).toBe(0); + }); + + it('reads keys, the v2 property types and nullability', () => { + expect(model.entityTypes.get(`${NS}.Order_Detail`)?.key).toEqual(['OrderID', 'ProductID']); + const order = model.entityTypes.get(`${NS}.Order`)!; + expect(order.properties.find((p) => p.name === 'OrderDate')).toEqual({ name: 'OrderDate', type: 'Edm.DateTime', isCollection: false, nullable: true }); + expect(order.properties.find((p) => p.name === 'OrderID')?.nullable).toBe(false); + const shipment = model.entityTypes.get(`${NS}.Shipment`)!; + expect(shipment.properties.map((p) => p.type)).toEqual([ + 'Edm.Guid', 'Edm.Int64', 'Edm.Double', 'Edm.Single', 'Edm.DateTimeOffset', 'Edm.Time', 'Edm.String', + ]); + }); + + it('reads a navigation through its Association: the target type and cardinality from the ToRole end', () => { + const product = model.entityTypes.get(`${NS}.Product`)!; + expect(product.navigationProperties.map((n) => [n.name, n.type, n.isCollection, n.nullable])).toEqual([ + ['Category', `${NS}.Category`, false, true], + ['Order_Details', `${NS}.Order_Detail`, true, true], + ['Supplier', `${NS}.Supplier`, false, true], + ]); + // Multiplicity "1" on the target end: not nullable. + const line = model.entityTypes.get(`${NS}.Order_Detail`)!; + expect(line.navigationProperties.find((n) => n.name === 'Product')).toMatchObject({ isCollection: false, nullable: false }); + }); + + it('gives the dependent end the ReferentialConstraint as v4 writes it: this property → the target\'s', () => { + const product = model.entityTypes.get(`${NS}.Product`)!; + expect(product.navigationProperties.find((n) => n.name === 'Category')?.referentialConstraints) + .toEqual([{ property: 'CategoryID', referencedProperty: 'CategoryID' }]); + expect(product.navigationProperties.find((n) => n.name === 'Supplier')?.referentialConstraints) + .toEqual([{ property: 'SupplierID', referencedProperty: 'SupplierID' }]); + // The principal end carries none: its own key is not a foreign key. + const category = model.entityTypes.get(`${NS}.Category`)!; + expect(category.navigationProperties.find((n) => n.name === 'Products')?.referentialConstraints).toEqual([]); + }); + + it('reads a self-association by its roles: the manager navigation holds ReportsTo, the reports one holds nothing', () => { + const employee = model.entityTypes.get(`${NS}.Employee`)!; + expect(employee.navigationProperties.find((n) => n.name === 'Employee1')).toEqual({ + name: 'Employee1', type: `${NS}.Employee`, isCollection: false, nullable: true, + referentialConstraints: [{ property: 'ReportsTo', referencedProperty: 'EmployeeID' }], + }); + expect(employee.navigationProperties.find((n) => n.name === 'Employees1')).toMatchObject({ isCollection: true, referentialConstraints: [] }); + }); + + it('binds each navigation to its target entity set through the AssociationSets', () => { + expect(model.entitySets.get('Products')?.navigationBindings).toEqual({ + Category: 'Categories', Order_Details: 'Order_Details', Supplier: 'Suppliers', + }); + expect(model.entitySets.get('Employees')?.navigationBindings).toEqual({ + Employees1: 'Employees', Employee1: 'Employees', Orders: 'Orders', + }); + expect(model.entitySets.get('ShipmentLegs')?.navigationBindings).toEqual({ Shipment: 'Shipments' }); + }); + + it('refuses a v4 (CSDL 4.0) document by name, pointing at the version that reads it', () => { + expect(() => parseCsdlV2(TRIPPIN_METADATA)).toThrow(/not EDMX 1\.0 .*Version="4\.0".*version: '4\.0'/); + }); + + it('refuses a document that is not XML', () => { + expect(() => parseCsdlV2('{"d":[]}')).toThrow(/not EDMX 1\.0/); + }); +}); + +describe('the v2 verbose JSON envelope', () => { + it('reads d.results, the string __count and __next, and drops each navigation\'s __deferred stub', () => { + const page = odataV2Dialect.readCollection({ d: { results: [PRODUCTS_V2[0]], __count: '77', __next: 'https://x/Products?$skiptoken=1' } }); + expect(page.count).toBe(77); + expect(page.nextLink).toBe('https://x/Products?$skiptoken=1'); + expect(Object.keys(page.rows[0]!)).toEqual([ + '__metadata', 'ProductID', 'ProductName', 'SupplierID', 'CategoryID', 'UnitPrice', 'Discontinued', + ]); + }); + + it('reads a 1.0 service\'s bare d array', () => { + expect(odataV2Dialect.readCollection({ d: [{ A: 1 }] })).toEqual({ rows: [{ A: 1 }] }); + }); + + it('refuses a v4 envelope rather than reading no rows', () => { + expect(() => odataV2Dialect.readCollection({ value: [] })).toThrow(/without a `d.results` array/); + }); + + it('reads one entity out of d, and its ETag out of __metadata', () => { + const entity = odataV2Dialect.readEntity({ d: SHIPMENT_V2 }); + expect(entity.Legs).toBeUndefined(); + expect(odataV2Dialect.etagOf(entity)).toBe(SHIPMENT_V2.__metadata.etag); + expect(odataV2Dialect.isAnnotation('__metadata')).toBe(true); + expect(odataV2Dialect.isAnnotation('Label')).toBe(false); + expect(() => odataV2Dialect.readEntity({ value: [] })).toThrow(/without a `d` entity/); + }); + + it('reads the v2 error body, whose message is { lang, value }', () => { + expect(odataV2Dialect.readError({ error: { code: 'BadRequest', message: { lang: 'en-US', value: 'Syntax error.' } } })) + .toEqual({ code: 'BadRequest', message: 'Syntax error.' }); + expect(odataV2Dialect.readError({ error: { code: '', message: 'plain' } })).toEqual({ message: 'plain' }); + }); + + it('asks for verbose JSON under DataServiceVersion 2.0, and counts with $inlinecount', () => { + expect(odataV2Dialect.requestHeaders()).toEqual({ DataServiceVersion: '2.0', MaxDataServiceVersion: '2.0', Accept: 'application/json' }); + expect(odataV2Dialect.countOptions()).toEqual({ $inlinecount: 'allpages' }); + }); +}); + +describe('v2 literals', () => { + const lit = (value: unknown, type: string) => odataV2Dialect.literal(value, type, model); + + it('spells each typed literal of the v2 URI conventions', () => { + expect(lit("O'Neil", 'Edm.String')).toBe("'O''Neil'"); + expect(lit(true, 'Edm.Boolean')).toBe('true'); + expect(lit(7, 'Edm.Int32')).toBe('7'); + expect(lit(7, 'Edm.Int16')).toBe('7'); + expect(lit(7, 'Edm.Int64')).toBe('7L'); + expect(lit('9007199254740993', 'Edm.Int64')).toBe('9007199254740993L'); + expect(lit(1.5, 'Edm.Decimal')).toBe('1.5M'); + expect(lit('21.3500', 'Edm.Decimal')).toBe('21.3500M'); + expect(lit(1.5, 'Edm.Double')).toBe('1.5d'); + expect(lit(2, 'Edm.Double')).toBe('2d'); + expect(lit(0.75, 'Edm.Single')).toBe('0.75f'); + expect(lit('6f1e2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b', 'Edm.Guid')).toBe("guid'6f1e2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b'"); + expect(lit('2024-03-01T10:00:00Z', 'Edm.DateTimeOffset')).toBe("datetimeoffset'2024-03-01T10:00:00Z'"); + expect(lit('13:20', 'Edm.Time')).toBe("time'PT13H20M0S'"); + expect(lit('13:20:05.5', 'Edm.Time')).toBe("time'PT13H20M5.5S'"); + expect(lit(null, 'Edm.DateTime')).toBe('null'); + }); + + it('spells an Edm.DateTime comparand as its instant\'s UTC wall clock, and only one that names its instant', () => { + expect(lit('2024-03-01T10:00:00Z', 'Edm.DateTime')).toBe("datetime'2024-03-01T10:00:00.000'"); + expect(lit('2024-03-01T12:00:00+02:00', 'Edm.DateTime')).toBe("datetime'2024-03-01T10:00:00.000'"); + expect(lit(new Date('2024-03-01T10:00:00Z'), 'Edm.DateTime')).toBe("datetime'2024-03-01T10:00:00.000'"); + // A bare date or a zone-less date-time is left to the engine, not given a zone here. + expect(lit('2024-03-01', 'Edm.DateTime')).toBeUndefined(); + expect(lit('2024-03-01T10:00:00', 'Edm.DateTime')).toBeUndefined(); + }); + + it('refuses what cannot be spelled as the type, rather than coercing', () => { + expect(lit(1.5, 'Edm.Int64')).toBeUndefined(); + expect(lit(1e21, 'Edm.Decimal')).toBeUndefined(); + expect(lit('abc', 'Edm.Guid')).toBeUndefined(); + expect(lit('25:00', 'Edm.Time')).toBeUndefined(); + expect(lit('2024-03-01T10:00:00', 'Edm.DateTimeOffset')).toBeUndefined(); + expect(lit('x', 'Edm.Binary')).toBeUndefined(); + }); + + it('spells contains as substringof, needle first; startswith and endswith as v4 does', () => { + expect(odataV2Dialect.stringFunction('contains', 'ProductName', "'Chef'")).toBe("substringof('Chef',ProductName)"); + expect(odataV2Dialect.stringFunction('startswith', 'ProductName', "'Ch'")).toBe("startswith(ProductName,'Ch')"); + expect(odataV2Dialect.stringFunction('endswith', 'ProductName', "'ix'")).toBe("endswith(ProductName,'ix')"); + expect(odataV2Dialect.inList('ProductID', ['1', '2'])).toBe('ProductID eq 1 or ProductID eq 2'); + }); + + it('reads a key literal back to the value it spells again', () => { + for (const [value, type] of [ + [7, 'Edm.Int64'], ['6f1e2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b', 'Edm.Guid'], ['2024-03-01T10:00:00.000Z', 'Edm.DateTime'], + ['2024-03-01T10:00:00Z', 'Edm.DateTimeOffset'], ['13:20:00', 'Edm.Time'], ["O'Neil", 'Edm.String'], [1.5, 'Edm.Decimal'], [3, 'Edm.Int32'], + ] as const) { + const spelled = lit(value, type)!; + expect(lit(odataV2Dialect.keyValue!(spelled, type), type), `${type} ${spelled}`).toBe(spelled); + } + }); +}); + +describe('v2 values', () => { + const read = (value: unknown, type: string) => odataV2Dialect.readValue!(value, prop(type)); + const write = (value: unknown, type: string) => odataV2Dialect.writeValue!(value, prop(type)); + + it('reads /Date(ms)/ as the UTC instant, Int64 and Decimal strings as numbers, Edm.Time as the time of day', () => { + expect(read('/Date(836438400000)/', 'Edm.DateTime')).toBe('1996-07-04T00:00:00.000Z'); + expect(read('/Date(-664761600000)/', 'Edm.DateTime')).toBe('1948-12-08T00:00:00.000Z'); + expect(read('/Date(1709287200000+0060)/', 'Edm.DateTimeOffset')).toBe('2024-03-01T10:00:00.000Z'); + expect(read('2024-03-01T10:00:00Z', 'Edm.DateTimeOffset')).toBe('2024-03-01T10:00:00Z'); + expect(read('42', 'Edm.Int64')).toBe(42); + expect(read('21.3500', 'Edm.Decimal')).toBe(21.35); + expect(read('PT13H20M', 'Edm.Time')).toBe('13:20:00'); + expect(read('PT9H5M7.25S', 'Edm.Time')).toBe('09:05:07.25'); + expect(read(null, 'Edm.DateTime')).toBeNull(); + }); + + it('reads an Edm.DateTime under a field declared `date` as its UTC date: v2 has no Edm.Date', () => { + expect(odataV2Dialect.readValue!('/Date(-664761600000)/', prop('Edm.DateTime'), 'date')).toBe('1948-12-08'); + expect(odataV2Dialect.readValue!('/Date(-664761600000)/', prop('Edm.DateTime'), 'datetime')).toBe('1948-12-08T00:00:00.000Z'); + }); + + it('keeps what it cannot read losslessly as written', () => { + expect(read('9007199254740993', 'Edm.Int64')).toBe('9007199254740993'); + expect(read('P1DT2H', 'Edm.Time')).toBe('P1DT2H'); + expect(read('not a date', 'Edm.DateTime')).toBe('not a date'); + expect(read('Chai', 'Edm.String')).toBe('Chai'); + }); + + it('writes the v2 JSON forms: /Date(ms)/ read as UTC, Int64 and Decimal as strings, a time as a duration', () => { + expect(write('1996-07-04T00:00:00.000Z', 'Edm.DateTime')).toBe('/Date(836438400000)/'); + expect(write('1996-07-04', 'Edm.DateTime')).toBe('/Date(836438400000)/'); + expect(write('1996-07-04T00:00:00', 'Edm.DateTime')).toBe('/Date(836438400000)/'); + expect(write(new Date(836438400000), 'Edm.DateTime')).toBe('/Date(836438400000)/'); + expect(write(42, 'Edm.Int64')).toBe('42'); + expect(write(21.35, 'Edm.Decimal')).toBe('21.35'); + expect(write('13:20', 'Edm.Time')).toBe('PT13H20M0S'); + expect(write(new Date('2024-03-01T10:00:00Z'), 'Edm.DateTimeOffset')).toBe('2024-03-01T10:00:00.000Z'); + expect(write(12.5, 'Edm.Double')).toBe(12.5); + expect(write(null, 'Edm.DateTime')).toBeNull(); + }); + + it('updates with MERGE tunnelled through POST', () => { + expect(odataV2Dialect.updateRequest!()).toEqual({ method: 'POST', headers: { 'X-HTTP-Method': 'MERGE' } }); + }); +}); diff --git a/packages/drivers/driver-odata/src/dialect-v2.ts b/packages/drivers/driver-odata/src/dialect-v2.ts new file mode 100644 index 00000000000..4470e29c27e --- /dev/null +++ b/packages/drivers/driver-odata/src/dialect-v2.ts @@ -0,0 +1,500 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The OData 2.0 dialect (`version: '2.0'`): the public v2 protocol, as + * Microsoft's open specifications write it ([MS-ODATA], [MS-CSDL] and the + * OData v2 URI Conventions). It implements the same {@link ODataDialect} as + * `dialect-v4.ts`; the driver and the filter translator do not change. + * + * What v2 spells differently from v4: + * + * - Requests carry `DataServiceVersion` / `MaxDataServiceVersion` and ask for + * `application/json`, which a v2 service answers in its verbose JSON: a + * collection is `{ d: { results, __count, __next } }` (a 1.0 service writes + * `{ d: [ … ] }`), an entity is `{ d: { … } }`, and each entity carries + * `__metadata` (with its `etag`) and a `{ __deferred }` stub per navigation + * property, which is not a value and is dropped. + * - Values: `Edm.DateTime` is written `/Date()/`, read as that + * UTC instant (or, under a field declared `date`, its UTC date: v2 has no + * `Edm.Date`); `Edm.Int64` and `Edm.Decimal` are strings; `Edm.Time` is a + * duration since midnight (`PT13H20M`), read as the time of day. + * - The count is `$inlinecount=allpages`. + * - `$metadata` is EDMX 1.0. A navigation property names an `Association` + * (`Relationship`, `FromRole`, `ToRole`): the association's ends give the + * target type and cardinality, its `ReferentialConstraint` the key columns, + * and the container's `AssociationSet`s the target entity set. They are read + * into the same {@link EdmNavigationProperty} shape v4 yields, so an import + * drafts the same lookups over either version. + * - Literals are typed: `datetime'…'`, `datetimeoffset'…'`, `time'PT…'`, + * `guid'…'`, `1L`, `1.5M`, `1.5d`, `1.5f`; keys take the same spellings + * (`Orders(guid'…')`, `Items(1L)`). + * - `contains` is `substringof('x',P)`. + * - A partial update is `MERGE`, sent as a `POST` tunnelled with + * `X-HTTP-Method: MERGE`, which every v2 service accepts and no + * intermediary refuses as an unknown verb. + * + * ⛔ Nothing vendor-specific lives here (the 2026-10-29 ruling): no CSRF token + * handshake, no `sap-client`, no `sap:*` annotations. A service that needs one + * is served by a vendor plugin. + */ + +import type { + EdmEntitySet, + EdmNavigationProperty, + ODataCollectionPage, + ODataDialect, + ODataServiceModel, + ODataStringFunction, +} from './dialect.js'; +import { + attr, + children, + flattenEntityTypes, + unwrapCollection, + xmlParser, + type RawEntityType, + type XmlNode, +} from './dialect-v4.js'; + +const SMALL_INTEGER_TYPES: ReadonlySet = new Set(['Edm.Byte', 'Edm.SByte', 'Edm.Int16', 'Edm.Int32']); +/** Literal suffix by type: v2's typed numeric literals. */ +const NUMBER_SUFFIX: Readonly> = { 'Edm.Int64': 'L', 'Edm.Decimal': 'M', 'Edm.Double': 'd', 'Edm.Single': 'f' }; + +const INTEGER_TEXT = /^-?\d+$/; +/** v2's decimal literal has no exponent. */ +const DECIMAL_TEXT = /^-?\d+(\.\d+)?$/; +const FLOAT_TEXT = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/; +const GUID_TEXT = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; +/** A date-time WITH its zone: the only text read as one instant on both sides. */ +const ZONED_DATE_TIME_TEXT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,12})?)?(Z|[+-]\d{2}:\d{2})$/; +/** A date-time without a zone; `Edm.DateTime` values are read as UTC, so it is written as one. */ +const LOCAL_DATE_TIME_TEXT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,12})?)?$/; +const DATE_TEXT = /^\d{4}-\d{2}-\d{2}$/; +const TIME_OF_DAY_TEXT = /^(\d{2}):(\d{2})(?::(\d{2})(\.\d{1,12})?)?$/; +/** `/Date(1700000000000)/`, with the optional offset v2 writes on a `DateTimeOffset` (`+0060`, minutes). */ +const JSON_DATE = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/; +/** A duration within one day, as v2 writes an `Edm.Time`. */ +const DAY_DURATION = /^PT(?:(\d{1,2})H)?(?:(\d{1,2})M)?(?:(\d{1,2})(\.\d{1,12})?S)?$/; + +const quote = (text: string) => `'${text.replace(/'/g, "''")}'`; +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +/** A v2 `/Date(ms)/` → the ISO instant; `undefined` when it is not one. */ +function fromJsonDate(value: string): string | undefined { + const match = JSON_DATE.exec(value); + if (!match) return undefined; + const date = new Date(Number(match[1])); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +/** A zoned date-time or a `Date` → milliseconds since the epoch; `undefined` otherwise. */ +function instantOf(value: unknown): number | undefined { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.getTime(); + if (typeof value !== 'string' || !ZONED_DATE_TIME_TEXT.test(value)) return undefined; + const ms = Date.parse(value); + return Number.isNaN(ms) ? undefined : ms; +} + +/** The UTC wall-clock text of an instant, as a v2 `datetime'…'` body: `2024-01-15T10:00:00.000`. */ +const utcWallClock = (ms: number) => new Date(ms).toISOString().slice(0, -1); + +/** `13:20` / `13:20:05.5` → `PT13H20M0S` / `PT13H20M5.5S`. */ +function toDuration(value: string): string | undefined { + const match = TIME_OF_DAY_TEXT.exec(value); + if (!match) return undefined; + const [, h, m, s = '00', fraction = ''] = match; + if (Number(h) > 23 || Number(m) > 59 || Number(s) > 59) return undefined; + return `PT${Number(h)}H${Number(m)}M${Number(s)}${fraction}S`; +} + +/** `PT13H20M` → `13:20:00`; `undefined` for anything that is not a duration within one day. */ +function fromDuration(value: string): string | undefined { + const match = DAY_DURATION.exec(value); + if (!match || value === 'PT') return undefined; + const [, h = '0', m = '0', s = '0', fraction = ''] = match; + if (Number(h) > 23 || Number(m) > 59 || Number(s) > 59) return undefined; + const two = (n: string) => n.padStart(2, '0'); + return `${two(h)}:${two(m)}:${two(s)}${fraction}`; +} + +/** Drop the `{ __deferred }` stubs a v2 entity carries for its navigation properties. */ +function withoutDeferred(entity: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(entity)) { + if (isRecord(value) && '__deferred' in value) continue; + out[key] = value; + } + return out; +} + +interface Association { + /** Role → the end's qualified entity type and multiplicity (`1`, `0..1`, `*`). */ + ends: Map; + constraint?: { principalRole: string; principal: string[]; dependentRole: string; dependent: string[] }; +} + +/** Parse an EDMX 1.0 (CSDL v1/v2) `$metadata` document into the {@link ODataServiceModel} the driver reads. */ +export function parseCsdlV2(xml: string): ODataServiceModel { + let document: XmlNode; + try { + document = xmlParser.parse(xml) as XmlNode; + } catch (error) { + throw new Error(`driver-odata: the $metadata document is not well-formed XML (${(error as Error).message})`); + } + const edmx = children(document, 'Edmx')[0]; + const edmxVersion = edmx ? attr(edmx, 'Version') : undefined; + if (!edmx || !edmxVersion || !edmxVersion.startsWith('1.')) { + throw new Error( + `driver-odata: the $metadata document is not EDMX 1.0 (edmx:Edmx Version="${edmxVersion ?? 'absent'}"); ` + + "this is the v2 dialect only; an OData v4 service is read with `version: '4.0'`", + ); + } + const dataServices = children(edmx, 'DataServices'); + // `m:DataServiceVersion` (the `m:` prefix is stripped by the parser): 1.0 or 2.0 on a v2 service. + const version = dataServices.map((ds) => attr(ds, 'DataServiceVersion')).find((v) => v !== undefined) ?? '1.0'; + const schemas = dataServices.flatMap((ds) => children(ds, 'Schema')); + + const aliases = new Map(); + for (const schema of schemas) { + const namespace = attr(schema, 'Namespace'); + const alias = attr(schema, 'Alias'); + if (namespace && alias) aliases.set(alias, namespace); + } + const qualify = (name: string): string => { + const { type, isCollection } = unwrapCollection(name); + const dot = type.lastIndexOf('.'); + const prefix = dot > 0 ? type.slice(0, dot) : ''; + const resolved = aliases.has(prefix) ? `${aliases.get(prefix)}${type.slice(dot)}` : type; + return isCollection ? `Collection(${resolved})` : resolved; + }; + + const associations = new Map(); + for (const schema of schemas) { + const namespace = attr(schema, 'Namespace') ?? ''; + for (const association of children(schema, 'Association')) { + const name = attr(association, 'Name'); + if (!name) continue; + const ends = new Map(); + for (const end of children(association, 'End')) { + const role = attr(end, 'Role'); + const type = attr(end, 'Type'); + if (role && type) ends.set(role, { type: qualify(type), multiplicity: attr(end, 'Multiplicity') ?? '*' }); + } + const rc = children(association, 'ReferentialConstraint')[0]; + const principal = rc ? children(rc, 'Principal')[0] : undefined; + const dependent = rc ? children(rc, 'Dependent')[0] : undefined; + const refs = (node: XmlNode) => + children(node, 'PropertyRef').map((ref) => attr(ref, 'Name')).filter((n): n is string => typeof n === 'string'); + const principalRole = principal ? attr(principal, 'Role') : undefined; + const dependentRole = dependent ? attr(dependent, 'Role') : undefined; + associations.set(`${namespace}.${name}`, { + ends, + ...(principal && dependent && principalRole && dependentRole + ? { constraint: { principalRole, principal: refs(principal), dependentRole, dependent: refs(dependent) } } + : {}), + }); + } + } + + /** A navigation's association and the role it navigates from, for the association sets below. */ + const navigationRoles = new Map>(); + const raw = new Map(); + for (const schema of schemas) { + const namespace = attr(schema, 'Namespace') ?? ''; + for (const entityType of children(schema, 'EntityType')) { + const name = attr(entityType, 'Name'); + if (!name) continue; + const fullName = `${namespace}.${name}`; + const baseType = attr(entityType, 'BaseType'); + const roles = new Map(); + navigationRoles.set(fullName, roles); + raw.set(fullName, { + fullName, + name, + ...(baseType ? { baseType: qualify(baseType) } : {}), + key: children(entityType, 'Key') + .flatMap((key) => children(key, 'PropertyRef')) + .map((ref) => attr(ref, 'Name')) + .filter((n): n is string => typeof n === 'string'), + properties: children(entityType, 'Property').flatMap((property) => { + const propertyName = attr(property, 'Name'); + const declared = attr(property, 'Type'); + if (!propertyName || !declared) return []; + const { type, isCollection } = unwrapCollection(qualify(declared)); + return [{ name: propertyName, type, isCollection, nullable: attr(property, 'Nullable') !== 'false' }]; + }), + navigationProperties: children(entityType, 'NavigationProperty').flatMap((nav): EdmNavigationProperty[] => { + const navName = attr(nav, 'Name'); + const relationship = attr(nav, 'Relationship'); + const fromRole = attr(nav, 'FromRole'); + const toRole = attr(nav, 'ToRole'); + if (!navName || !relationship || !fromRole || !toRole) return []; + const associationName = qualify(relationship); + const association = associations.get(associationName); + const target = association?.ends.get(toRole); + if (!association || !target) return []; + roles.set(navName, { association: associationName, fromRole }); + const { constraint } = association; + // A constraint is a lookup's columns only when this end is the dependent one: the + // dependent properties live on this type and reference the target's (principal's) key. + const referentialConstraints = constraint && constraint.dependentRole === fromRole && constraint.principalRole === toRole + ? constraint.dependent.flatMap((property, i) => { + const referencedProperty = constraint.principal[i]; + return referencedProperty ? [{ property, referencedProperty }] : []; + }) + : []; + return [{ + name: navName, + type: target.type, + isCollection: target.multiplicity === '*', + nullable: target.multiplicity !== '1', + referentialConstraints, + }]; + }), + }); + } + } + + const entityTypes = flattenEntityTypes(raw); + + // The association set ends: (association, role) → entity set. + const setOfEnd = new Map(); + const containers = schemas.flatMap((schema) => children(schema, 'EntityContainer')); + for (const container of containers) { + for (const associationSet of children(container, 'AssociationSet')) { + const association = attr(associationSet, 'Association'); + if (!association) continue; + for (const end of children(associationSet, 'End')) { + const role = attr(end, 'Role'); + const entitySet = attr(end, 'EntitySet'); + if (role && entitySet) setOfEnd.set(`${qualify(association)}\u0000${role}`, entitySet); + } + } + } + + const entitySets = new Map(); + for (const container of containers) { + for (const set of children(container, 'EntitySet')) { + const name = attr(set, 'Name'); + const declared = attr(set, 'EntityType'); + if (!name || !declared) continue; + const entityType = qualify(declared); + const navigationBindings: Record = {}; + for (const nav of entityTypes.get(entityType)?.navigationProperties ?? []) { + const roles = roleOf(entityType, nav.name, raw, navigationRoles); + if (!roles) continue; + // The set this navigation reaches: the association set whose from-end is this set. + if (setOfEnd.get(`${roles.association}\u0000${roles.fromRole}`) !== name) continue; + const association = associations.get(roles.association); + const toRole = association && [...association.ends.keys()].find((role) => role !== roles.fromRole); + const target = toRole === undefined ? undefined : setOfEnd.get(`${roles.association}\u0000${toRole}`); + if (target) navigationBindings[nav.name] = target; + } + entitySets.set(name, { name, entityType, navigationBindings }); + } + } + + return { version, entityTypes, entitySets, enumTypes: new Set() }; +} + +/** A navigation's association and from-role, looked up on the type that declares it (itself or a base). */ +function roleOf( + entityType: string, + navigation: string, + raw: ReadonlyMap, + navigationRoles: ReadonlyMap>, +): { association: string; fromRole: string } | undefined { + const seen = new Set(); + for (let type: string | undefined = entityType; type && !seen.has(type); type = raw.get(type)?.baseType) { + seen.add(type); + const found = navigationRoles.get(type)?.get(navigation); + if (found) return found; + } + return undefined; +} + +export const odataV2Dialect: ODataDialect = { + version: '2.0', + + requestHeaders() { + return { + DataServiceVersion: '2.0', + MaxDataServiceVersion: '2.0', + Accept: 'application/json', + }; + }, + + readCollection(body: unknown): ODataCollectionPage { + const d = isRecord(body) ? body.d : undefined; + // 2.0 wraps the rows in `results`; a 1.0 service answers the array itself. + const results = Array.isArray(d) ? d : isRecord(d) ? d.results : undefined; + if (!Array.isArray(results)) { + throw new Error('driver-odata: the service answered a collection read without a `d.results` array'); + } + const page: ODataCollectionPage = { rows: results.filter(isRecord).map(withoutDeferred) }; + if (isRecord(d)) { + const next = d.__next; + if (typeof next === 'string' && next !== '') page.nextLink = next; + const count = d.__count; + if (typeof count === 'number') page.count = count; + else if (typeof count === 'string' && INTEGER_TEXT.test(count)) page.count = Number(count); + } + return page; + }, + + readEntity(body: unknown) { + const d = isRecord(body) ? body.d : undefined; + // A service that wraps one entity like a collection page (`{ d: { results: { … } } }`). + const entity = isRecord(d) && isRecord(d.results) ? d.results : d; + if (!isRecord(entity)) throw new Error('driver-odata: the service answered an entity read without a `d` entity'); + return withoutDeferred(entity); + }, + + etagOf(entity) { + const metadata = entity.__metadata; + const etag = isRecord(metadata) ? metadata.etag : undefined; + return typeof etag === 'string' && etag !== '' ? etag : undefined; + }, + + isAnnotation(key) { + // `__metadata`, and any other `__` member of the verbose envelope. + return key.startsWith('__'); + }, + + readError(body) { + if (!isRecord(body) || !isRecord(body.error)) return {}; + const { code, message } = body.error; + // v2 writes `message: { lang, value }`; accept the bare string too. + const text = isRecord(message) ? message.value : message; + return { + ...(typeof code === 'string' && code !== '' ? { code } : {}), + ...(typeof text === 'string' && text !== '' ? { message: text } : {}), + }; + }, + + parseMetadata: parseCsdlV2, + + literal(value, type) { + if (value === null) return 'null'; + if (type === 'Edm.String') return typeof value === 'string' ? quote(value) : undefined; + if (type === 'Edm.Boolean') return typeof value === 'boolean' ? String(value) : undefined; + if (SMALL_INTEGER_TYPES.has(type)) { + if (typeof value === 'number') return Number.isSafeInteger(value) ? String(value) : undefined; + return typeof value === 'string' && INTEGER_TEXT.test(value) ? value : undefined; + } + if (type === 'Edm.Int64') { + if (typeof value === 'number') return Number.isSafeInteger(value) ? `${value}L` : undefined; + return typeof value === 'string' && INTEGER_TEXT.test(value) ? `${value}L` : undefined; + } + if (type === 'Edm.Decimal' || type === 'Edm.Double' || type === 'Edm.Single') { + const pattern = type === 'Edm.Decimal' ? DECIMAL_TEXT : FLOAT_TEXT; + const text = typeof value === 'number' ? (Number.isFinite(value) ? String(value) : undefined) + : typeof value === 'string' ? value : undefined; + return text !== undefined && pattern.test(text) ? `${text}${NUMBER_SUFFIX[type]}` : undefined; + } + if (type === 'Edm.Guid') return typeof value === 'string' && GUID_TEXT.test(value) ? `guid'${value}'` : undefined; + if (type === 'Edm.DateTime') { + // The service's value has no zone and is read as UTC; only a comparand that names its + // instant is spelled, as that instant's UTC wall clock. A bare date or a zone-less + // date-time is left to the engine rather than given a zone here. + const ms = instantOf(value); + return ms === undefined ? undefined : `datetime'${utcWallClock(ms)}'`; + } + if (type === 'Edm.DateTimeOffset') { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : `datetimeoffset'${value.toISOString()}'`; + return typeof value === 'string' && ZONED_DATE_TIME_TEXT.test(value) ? `datetimeoffset'${value}'` : undefined; + } + if (type === 'Edm.Time') { + const duration = typeof value === 'string' ? toDuration(value) : undefined; + return duration === undefined ? undefined : `time'${duration}'`; + } + // Binary and complex types: no spelling is claimed. v2 has no enum types. + return undefined; + }, + + stringFunction(fn: ODataStringFunction, property: string, literal: string) { + // v2 has no `contains`: `substringof` takes the needle first. + return fn === 'contains' ? `substringof(${literal},${property})` : `${fn}(${property},${literal})`; + }, + + toLower(property) { + return `tolower(${property})`; + }, + + inList(property, literals) { + // v2 has no `in` operator: an `or` chain. + return literals.map((literal) => `${property} eq ${literal}`).join(' or '); + }, + + countOptions() { + return { $inlinecount: 'allpages' }; + }, + + readValue(value, property, fieldType) { + if (typeof value !== 'string' || property.isCollection) return value; + switch (property.type) { + case 'Edm.DateTime': { + // v2 has no Edm.Date: a date column is an Edm.DateTime at midnight, and a `date` field over it reads its date. + const instant = fromJsonDate(value); + if (instant === undefined) return value; + return fieldType === 'date' ? instant.slice(0, 10) : instant; + } + case 'Edm.DateTimeOffset': + return fromJsonDate(value) ?? value; + case 'Edm.Int64': + return INTEGER_TEXT.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : value; + case 'Edm.Decimal': + return FLOAT_TEXT.test(value) ? Number(value) : value; + case 'Edm.Time': + return fromDuration(value) ?? value; + default: + return value; + } + }, + + writeValue(value, property) { + if (value === null || value === undefined || property.isCollection) return value; + switch (property.type) { + case 'Edm.DateTime': { + // Read as UTC, so written as UTC: a zone-less date-time or a bare date names the UTC wall clock. + const text = typeof value === 'string' && (LOCAL_DATE_TIME_TEXT.test(value) || DATE_TEXT.test(value)) + ? `${DATE_TEXT.test(value) ? `${value}T00:00:00` : value}Z` + : value; + const ms = instantOf(text); + return ms === undefined ? value : `/Date(${ms})/`; + } + case 'Edm.DateTimeOffset': + return value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : value; + case 'Edm.Int64': + return typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value; + case 'Edm.Decimal': + return typeof value === 'number' && DECIMAL_TEXT.test(String(value)) ? String(value) : value; + case 'Edm.Time': + return typeof value === 'string' ? (toDuration(value) ?? value) : value; + default: + return value; + } + }, + + keyValue(literal, type) { + if (type === 'Edm.String') { + return literal.length >= 2 && literal.startsWith("'") && literal.endsWith("'") + ? literal.slice(1, -1).replace(/''/g, "'") + : undefined; + } + const typed = /^(guid|datetime|datetimeoffset|time)'(.*)'$/.exec(literal); + if (typed) { + const [, prefix, body] = typed; + if (prefix === 'datetime') return `${body}Z`; + if (prefix === 'time') return fromDuration(body!); + return body; + } + const suffixed = /^(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)[LlMmDdFf]$/.exec(literal); + return suffixed ? suffixed[1] : literal; + }, + + updateRequest() { + return { method: 'POST', headers: { 'X-HTTP-Method': 'MERGE' } }; + }, +}; diff --git a/packages/drivers/driver-odata/src/dialect-v4.ts b/packages/drivers/driver-odata/src/dialect-v4.ts index ea2b49a3fad..fcca5d2d28e 100644 --- a/packages/drivers/driver-odata/src/dialect-v4.ts +++ b/packages/drivers/driver-odata/src/dialect-v4.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The OData 4.0 dialect — the one this package ships (maintainer order: v4 - * only; a v2 dialect is a sibling file implementing {@link ODataDialect}). + * The OData 4.0 dialect, the default (`version: '4.0'`). Its sibling + * `dialect-v2.ts` implements the same {@link ODataDialect} for OData 2.0, and + * reuses this file's XML helpers. * * References: OData Version 4.0 Part 1 (Protocol) §11.2.6.1 (system query * options), Part 2 (URL Conventions) §5.1.1 (`$filter` operators and @@ -22,7 +23,7 @@ import type { ODataStringFunction, } from './dialect.js'; -type XmlNode = Record; +export type XmlNode = Record; const INTEGER_TYPES: ReadonlySet = new Set(['Edm.Byte', 'Edm.SByte', 'Edm.Int16', 'Edm.Int32', 'Edm.Int64']); const DECIMAL_TYPES: ReadonlySet = new Set(['Edm.Decimal', 'Edm.Double', 'Edm.Single']); @@ -40,24 +41,24 @@ const MEMBER_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const quote = (text: string) => `'${text.replace(/'/g, "''")}'`; /** `Collection(X)` → `{ type: X, isCollection: true }`. */ -function unwrapCollection(type: string): { type: string; isCollection: boolean } { +export function unwrapCollection(type: string): { type: string; isCollection: boolean } { const match = /^Collection\((.+)\)$/.exec(type); return match ? { type: match[1]!, isCollection: true } : { type, isCollection: false }; } -const children = (node: unknown, name: string): XmlNode[] => { +export const children = (node: unknown, name: string): XmlNode[] => { if (!node || typeof node !== 'object') return []; const value = (node as XmlNode)[name]; return Array.isArray(value) ? (value as XmlNode[]) : []; }; -const attr = (node: XmlNode, name: string): string | undefined => { +export const attr = (node: XmlNode, name: string): string | undefined => { const value = node[`@_${name}`]; return typeof value === 'string' ? value : undefined; }; /** The parser: every element an array (so a single child reads like many), attributes kept as text. */ -const xmlParser = new XMLParser({ +export const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', removeNSPrefix: true, @@ -79,7 +80,7 @@ export function parseCsdlV4(xml: string): ODataServiceModel { if (!edmx || !version || !version.startsWith('4.')) { throw new Error( `driver-odata: the $metadata document is not CSDL 4.x (edmx:Edmx Version="${version ?? 'absent'}"); ` - + 'this driver ships the OData v4 dialect only', + + "this is the v4 dialect only; an OData v2 service is read with `version: '2.0'`", ); } const schemas = children(edmx, 'DataServices').flatMap((ds) => children(ds, 'Schema')); @@ -99,14 +100,6 @@ export function parseCsdlV4(xml: string): ODataServiceModel { return isCollection ? `Collection(${resolved})` : resolved; }; - interface RawEntityType { - fullName: string; - name: string; - baseType?: string; - key: string[]; - properties: EdmProperty[]; - navigationProperties: EdmNavigationProperty[]; - } const raw = new Map(); const enumTypes = new Set(); const entitySets = new Map(); @@ -171,7 +164,21 @@ export function parseCsdlV4(xml: string): ODataServiceModel { } } - // Flatten inheritance: base type first, keys from the nearest type that declares one. + return { version, entityTypes: flattenEntityTypes(raw), entitySets, enumTypes }; +} + +/** An entity type as its schema declares it, before inheritance is flattened. */ +export interface RawEntityType { + fullName: string; + name: string; + baseType?: string; + key: string[]; + properties: EdmProperty[]; + navigationProperties: EdmNavigationProperty[]; +} + +/** Flatten inheritance: base type first, keys from the nearest type that declares one. */ +export function flattenEntityTypes(raw: ReadonlyMap): Map { const entityTypes = new Map(); const resolve = (fullName: string, seen: Set): EdmEntityType | undefined => { const done = entityTypes.get(fullName); @@ -191,8 +198,7 @@ export function parseCsdlV4(xml: string): ODataServiceModel { return resolved; }; for (const fullName of raw.keys()) resolve(fullName, new Set()); - - return { version, entityTypes, entitySets, enumTypes }; + return entityTypes; } const isRecord = (value: unknown): value is Record => diff --git a/packages/drivers/driver-odata/src/dialect.ts b/packages/drivers/driver-odata/src/dialect.ts index 188083cbf85..cf2d2e6638e 100644 --- a/packages/drivers/driver-odata/src/dialect.ts +++ b/packages/drivers/driver-odata/src/dialect.ts @@ -119,4 +119,32 @@ export interface ODataDialect { inList(property: string, literals: readonly string[]): string; /** The query options that ask a collection read to carry its total count. */ countOptions(): Record; + + // The members below are optional: a dialect that leaves one out gets the v4 + // behaviour, which is what `odataV4Dialect` relies on. + + /** + * A property value as the service wrote it → the value a row carries (v2 + * writes `Edm.DateTime` as `/Date(ms)/` and `Edm.Int64` as a string). + * `fieldType` is the declared type of the field the property backs, when the + * object declares one: v2 has no `Edm.Date`, so a `date` field over an + * `Edm.DateTime` reads the date alone. Absent: the value is carried as + * written. + */ + readValue?(value: unknown, property: EdmProperty, fieldType?: string): unknown; + /** The inverse of {@link readValue}, for a request body. Absent: the value is sent as given. */ + writeValue?(value: unknown, property: EdmProperty): unknown; + /** + * Read back one literal this dialect spelled (a part of a composite key + * predicate, `1L`, `guid'…'`) as the value {@link literal} spells again. + * Absent: a quoted string is unquoted and any other literal is taken as it + * is written, which is v4's spelling. + */ + keyValue?(literal: string, type: string): unknown; + /** + * The request that applies a partial update to one entity. Absent: `PATCH`. + * v2 has no `PATCH`: its partial update is `MERGE`, sent as a `POST` + * tunnelled with `X-HTTP-Method` (MS-ODATA §2.2.5.8). + */ + updateRequest?(): { method: 'PATCH' | 'POST'; headers: Record }; } diff --git a/packages/drivers/driver-odata/src/index.ts b/packages/drivers/driver-odata/src/index.ts index 3685345a751..6f1a429d565 100644 --- a/packages/drivers/driver-odata/src/index.ts +++ b/packages/drivers/driver-odata/src/index.ts @@ -14,8 +14,9 @@ export { } from './config.js'; export type { ODataDriverConfig, ODataDriverConfigInput } from './config.js'; -// The dialect seam: a v2 dialect implements `ODataDialect` in a file of its own. +// The dialect seam: each OData version implements `ODataDialect` in a file of its own. export { odataV4Dialect, parseCsdlV4 } from './dialect-v4.js'; +export { odataV2Dialect, parseCsdlV2 } from './dialect-v2.js'; export type { EdmEntitySet, EdmEntityType, diff --git a/packages/drivers/driver-odata/src/odata-driver-v2.test.ts b/packages/drivers/driver-odata/src/odata-driver-v2.test.ts new file mode 100644 index 00000000000..4c7c41f4c38 --- /dev/null +++ b/packages/drivers/driver-odata/src/odata-driver-v2.test.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The driver with `version: '2.0'` against a recorded Northwind-shaped v2 + * service: each test names the exact requests the driver sends and answers + * them from recordings in the v2 verbose JSON envelope. + */ + +import { describe, expect, it } from 'vitest'; +import { parseODataDriverConfig } from './config.js'; +import { ODataDriver } from './odata-driver.js'; +import { ODataRequestError } from './transport.js'; +import { recordedService, type Exchange } from './__fixtures__/recorded-service.js'; +import { + NORTHWIND_V2_METADATA_EXCHANGE, + NORTHWIND_V2_ROOT, + ORDERS_V2, + PRODUCTS_V2, + SHIPMENT_LEG_V2, + SHIPMENT_V2, + TRACKING_ID, + entityV2, + pageV2, +} from './__fixtures__/recorded-service-v2.js'; + +const PRODUCT = { + name: 'product', + external: { + remoteName: 'Products', + columnMap: { + ProductID: 'product_id', ProductName: 'product_name', SupplierID: 'supplier_id', + CategoryID: 'category_id', UnitPrice: 'unit_price', Discontinued: 'discontinued', + }, + }, +}; +const ORDER = { name: 'order', external: { remoteName: 'Orders' } }; +const SHIPMENT = { name: 'shipment', external: { remoteName: 'Shipments' } }; +const LEG = { name: 'shipment_leg', external: { remoteName: 'ShipmentLegs' } }; + +function driverFor(exchanges: Exchange[]) { + const service = recordedService([NORTHWIND_V2_METADATA_EXCHANGE, ...exchanges], NORTHWIND_V2_ROOT); + const config = parseODataDriverConfig({ name: 'northwind', serviceRoot: NORTHWIND_V2_ROOT, version: '2.0', pageSize: 2 }); + const driver = new ODataDriver(config, { fetch: service.fetch }); + for (const object of [PRODUCT, ORDER, SHIPMENT, LEG]) driver.registerExternalObject(object); + return { driver, requests: service.requests }; +} + +const urls = (requests: { method: string; url: string }[]) => + requests.filter((r) => r.url !== '$metadata').map((r) => `${r.method} ${r.url}`); + +describe('version selection', () => { + it('accepts version 2.0 beside the default 4.0, and nothing else', () => { + expect(parseODataDriverConfig({ serviceRoot: NORTHWIND_V2_ROOT, version: '2.0' }).version).toBe('2.0'); + expect(parseODataDriverConfig({ serviceRoot: NORTHWIND_V2_ROOT }).version).toBe('4.0'); + expect(() => parseODataDriverConfig({ serviceRoot: NORTHWIND_V2_ROOT, version: '3.0' as never })).toThrow(/version/); + }); + + it('reads $metadata as EDMX 1.0 and sends every request under DataServiceVersion 2.0', async () => { + const { driver, requests } = driverFor([{ url: 'Products', body: pageV2([]) }]); + await driver.find('product', {}); + expect(requests[0]!.url).toBe('$metadata'); + expect(requests[1]!.headers).toMatchObject({ DataServiceVersion: '2.0', MaxDataServiceVersion: '2.0', Accept: 'application/json' }); + expect(requests[1]!.headers['OData-Version']).toBeUndefined(); + }); +}); + +describe('reads', () => { + it('reads d.results, renames through the column map, drops __metadata and the __deferred stubs, and sets id to the key', async () => { + const { driver, requests } = driverFor([ + { url: "Products?$filter=substringof('Chef',ProductName)", body: pageV2([PRODUCTS_V2[3]]) }, + ]); + const rows = await driver.find('product', { where: { product_name: { $contains: 'Chef' } } }); + expect(urls(requests)).toEqual(["GET Products?$filter=substringof('Chef',ProductName)"]); + expect(rows).toEqual([{ + id: '4', product_id: 4, product_name: "Chef Anton's Cajun Seasoning", supplier_id: 2, category_id: 2, + unit_price: 22, discontinued: false, + }]); + }); + + it('reads Edm.DateTime as the UTC instant and Edm.Decimal as a number', async () => { + const { driver } = driverFor([{ url: 'Orders', body: pageV2(ORDERS_V2) }]); + const [first] = await driver.find('order', {}); + expect(first).toMatchObject({ + id: '10248', OrderDate: '1996-07-04T00:00:00.000Z', ShippedDate: '1996-07-16T00:00:00.000Z', Freight: 32.38, + }); + }); + + it('reads an Edm.DateTime under a field the object declares `date` as its date', async () => { + const { driver } = driverFor([{ url: 'Orders', body: pageV2(ORDERS_V2) }]); + driver.registerExternalObject({ ...ORDER, fields: { OrderDate: { type: 'date' }, ShippedDate: { type: 'datetime' } } }); + const [first] = await driver.find('order', {}); + expect(first).toMatchObject({ OrderDate: '1996-07-04', ShippedDate: '1996-07-16T00:00:00.000Z' }); + }); + + it('pushes the v2 typed literals down: datetime, Decimal M, substringof under tolower', async () => { + const { driver, requests } = driverFor([ + { + url: "Orders?$filter=(OrderDate ge datetime'1996-07-05T00:00:00.000') and (Freight lt 20M)", + body: pageV2([ORDERS_V2[1]]), + }, + { url: "Products?$filter=substringof('chef',tolower(ProductName))", body: pageV2([PRODUCTS_V2[3], PRODUCTS_V2[4]]) }, + ]); + const orders = await driver.find('order', { where: { OrderDate: { $gte: '1996-07-05T00:00:00Z' }, Freight: { $lt: 20 } } }); + expect(orders.map((r) => r.id)).toEqual(['10249']); + const products = await driver.find('product', { where: { product_name: { $icontains: 'CHEF' } } }); + expect(products.map((r) => r.id)).toEqual(['4', '5']); + expect(urls(requests)).toEqual([ + "GET Orders?$filter=(OrderDate ge datetime'1996-07-05T00:00:00.000') and (Freight lt 20M)", + "GET Products?$filter=substringof('chef',tolower(ProductName))", + ]); + }); + + it('leaves a zone-less date comparand on Edm.DateTime to the engine rather than giving it a zone', async () => { + const { driver, requests } = driverFor([{ url: 'Orders', body: pageV2(ORDERS_V2) }]); + await driver.find('order', { where: { OrderDate: { $gte: '1996-07-05' } } }); + expect(urls(requests)).toEqual(['GET Orders']); + }); + + it('follows __next until the limit, ordered by the key', async () => { + const { driver, requests } = driverFor([ + { + url: 'Products?$orderby=ProductID asc&$top=3', + body: pageV2(PRODUCTS_V2.slice(0, 2), { __next: `${NORTHWIND_V2_ROOT}Products?$orderby=ProductID asc&$top=3&$skiptoken=2` }), + }, + { url: 'Products?$orderby=ProductID asc&$top=3&$skiptoken=2', body: pageV2(PRODUCTS_V2.slice(2, 4)) }, + ]); + const rows = await driver.find('product', { limit: 3 }); + expect(rows.map((r) => r.id)).toEqual(['1', '2', '3']); + expect(urls(requests)).toEqual([ + 'GET Products?$orderby=ProductID asc&$top=3', + 'GET Products?$orderby=ProductID asc&$top=3&$skiptoken=2', + ]); + }); + + it('counts with $inlinecount=allpages and reads the string __count', async () => { + const { driver, requests } = driverFor([ + { url: 'Products?$inlinecount=allpages&$top=0&$filter=Discontinued eq true', body: pageV2([], { __count: '8' }) }, + ]); + expect(await driver.count('product', { where: { discontinued: true } })).toBe(8); + expect(urls(requests)).toEqual(['GET Products?$inlinecount=allpages&$top=0&$filter=Discontinued eq true']); + }); + + it('answers a refused read with the v2 error body\'s { lang, value } message', async () => { + const { driver } = driverFor([ + { + url: "Products?$filter=ProductName eq 'x'", + status: 400, + body: { error: { code: '', message: { lang: 'en-US', value: 'Syntax error at position 12.' } } }, + }, + ]); + const error = await driver.find('product', { where: { product_name: 'x' } }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(ODataRequestError); + expect((error as Error).message).toBe("OData service refused GET on 'Products' (HTTP 400): Syntax error at position 12."); + }); +}); + +describe('keys', () => { + it('spells a Guid key as guid\'…\', keeps an Int64 beyond the safe range as text, reads Edm.Time as a time of day', async () => { + const { driver, requests } = driverFor([ + { url: `Shipments?$filter=TrackingId eq guid'${TRACKING_ID}'`, body: pageV2([SHIPMENT_V2]) }, + ]); + const [row] = await driver.find('shipment', { where: { id: TRACKING_ID } }); + expect(row).toMatchObject({ + id: TRACKING_ID, Sequence: '9007199254740993', Weight: 12.5, Volume: 0.75, + DispatchedAt: '2024-03-01T10:00:00Z', PickupWindow: '13:20:00', Label: 'Pallet 7', + }); + expect(urls(requests)).toEqual([`GET Shipments?$filter=TrackingId eq guid'${TRACKING_ID}'`]); + }); + + it('writes a composite key with typed literals and reads it back into the same predicate', async () => { + const predicate = `TrackingId=guid'${TRACKING_ID}',Leg=2L`; + const { driver, requests } = driverFor([ + { url: `ShipmentLegs?$filter=Destination eq 'Rotterdam'`, body: pageV2([SHIPMENT_LEG_V2]) }, + { method: 'DELETE', url: `ShipmentLegs(${predicate})`, status: 204 }, + ]); + const [leg] = await driver.find('shipment_leg', { where: { Destination: 'Rotterdam' } }); + expect(leg!.id).toBe(predicate); + expect(leg!.Leg).toBe(2); + expect(await driver.delete('shipment_leg', leg!.id as string)).toBe(true); + expect(urls(requests)).toContain(`DELETE ShipmentLegs(${predicate})`); + }); +}); + +describe('writes', () => { + it('updates with MERGE tunnelled through POST, carrying the __metadata ETag, then re-reads the 204', async () => { + const updated = { ...SHIPMENT_V2, Label: 'Pallet 8' }; + const { driver, requests } = driverFor([ + { url: `Shipments?$filter=TrackingId eq guid'${TRACKING_ID}'`, body: pageV2([SHIPMENT_V2]) }, + { method: 'POST', url: `Shipments(guid'${TRACKING_ID}')`, status: 204 }, + { url: `Shipments(guid'${TRACKING_ID}')`, body: entityV2(updated) }, + ]); + await driver.find('shipment', { where: { id: TRACKING_ID } }); + const row = await driver.update('shipment', TRACKING_ID, { Label: 'Pallet 8', PickupWindow: '14:00', Sequence: 3 }); + expect(row).toMatchObject({ id: TRACKING_ID, Label: 'Pallet 8' }); + const merge = requests.find((r) => r.method === 'POST')!; + expect(merge.url).toBe(`Shipments(guid'${TRACKING_ID}')`); + expect(merge.headers['X-HTTP-Method']).toBe('MERGE'); + expect(merge.headers['If-Match']).toBe(SHIPMENT_V2.__metadata.etag); + expect(merge.body).toEqual({ Label: 'Pallet 8', PickupWindow: 'PT14H0M0S', Sequence: '3' }); + }); + + it('creates with POST, writing Edm.DateTime as /Date(ms)/ and Edm.Decimal as a string, and reads the d entity back', async () => { + const created = { ...ORDERS_V2[0], OrderID: 11078 }; + const { driver, requests } = driverFor([{ method: 'POST', url: 'Orders', status: 201, body: entityV2(created) }]); + const row = await driver.create('order', { OrderID: 11078, OrderDate: '1996-07-04', Freight: 32.38, ShipName: 'x' }); + expect(row).toMatchObject({ id: '11078', OrderDate: '1996-07-04T00:00:00.000Z', Freight: 32.38 }); + expect(requests.find((r) => r.method === 'POST')!.body).toEqual({ + OrderID: 11078, OrderDate: '/Date(836438400000)/', Freight: '32.38', ShipName: 'x', + }); + }); +}); + +describe('introspection', () => { + it('lists the entity sets under dialect odata-2.0, with v2 types and Association-backed lookup candidates', async () => { + const { driver } = driverFor([]); + const schema = await driver.introspectSchema(); + expect(schema.dialect).toBe('odata-2.0'); + expect(schema.tables.Orders!.columns.find((c) => c.name === 'OrderDate')).toEqual({ + name: 'OrderDate', type: 'Edm.DateTime', nullable: true, primaryKey: false, + }); + expect(schema.tables.Products!.navigationProperties).toEqual([ + { + name: 'Category', targetEntityType: 'NorthwindModel.Category', targetEntitySet: 'Categories', nullable: true, + referentialConstraints: [{ property: 'CategoryID', referencedProperty: 'CategoryID' }], + }, + { + name: 'Supplier', targetEntityType: 'NorthwindModel.Supplier', targetEntitySet: 'Suppliers', nullable: true, + referentialConstraints: [{ property: 'SupplierID', referencedProperty: 'SupplierID' }], + }, + ]); + }); +}); diff --git a/packages/drivers/driver-odata/src/odata-driver.conformance-kit.test.ts b/packages/drivers/driver-odata/src/odata-driver.conformance-kit.test.ts index 62e46e3d4a4..434f286cc49 100644 --- a/packages/drivers/driver-odata/src/odata-driver.conformance-kit.test.ts +++ b/packages/drivers/driver-odata/src/odata-driver.conformance-kit.test.ts @@ -7,9 +7,10 @@ * in-repo plugin driver, so a kit change that a real driver cannot pass fails * in this repository first. * - * Each fixture is served by the in-process OData v4 service + * Each fixture is served by the in-process OData service * (`__fixtures__/odata-service.ts`), which evaluates the `$filter` the driver - * writes, over both collations. The floor tier only: the driver pushes down a + * writes, over both collations, speaking v4 and then v2 (with the driver's + * `version: '2.0'`). The floor tier only: the driver pushes down a * superset when a `where` does not translate whole, which the floor needs and * the direct tier (a driver that skips the floor) refuses by design. */ @@ -23,42 +24,50 @@ import { import { parseODataDriverConfig } from './config.js'; import { ODataDriver } from './odata-driver.js'; import type { FetchLike } from './transport.js'; -import { CONFORMANCE_ROOT, odataService, type EdmType, type ServiceProperty } from './__fixtures__/odata-service.js'; +import { + CONFORMANCE_ROOT, + odataService, + type EdmType, + type ODataVersion, + type ServiceProperty, +} from './__fixtures__/odata-service.js'; const ENTITY_SET = 'Rows'; -const EDM: Record = { - text: 'Edm.String', - number: 'Edm.Double', - datetime: 'Edm.DateTimeOffset', - date: 'Edm.Date', +type FieldType = PluginDriverConformanceFixture['fields'][string]['type']; +const EDM: Record> = { + '4.0': { text: 'Edm.String', number: 'Edm.Double', datetime: 'Edm.DateTimeOffset', date: 'Edm.Date' }, + // v2's CSDL has neither Edm.Date nor a usual DateTimeOffset column: both are Edm.DateTime. + '2.0': { text: 'Edm.String', number: 'Edm.Double', datetime: 'Edm.DateTime', date: 'Edm.DateTime' }, }; /** The driver over `fetch`, bound to the fixture's object as an external object on the service's one entity set. */ -function driverOver(fixture: PluginDriverConformanceFixture, fetch: FetchLike): PluginDriverConformanceTarget { - const driver = new ODataDriver(parseODataDriverConfig({ name: 'conformance', serviceRoot: CONFORMANCE_ROOT }), { fetch }); - driver.registerExternalObject({ name: fixture.object, external: { remoteName: ENTITY_SET } }); +function driverOver(fixture: PluginDriverConformanceFixture, fetch: FetchLike, version: ODataVersion): PluginDriverConformanceTarget { + const driver = new ODataDriver(parseODataDriverConfig({ name: 'conformance', serviceRoot: CONFORMANCE_ROOT, version }), { fetch }); + // The object as the engine registers it: with its declared fields (a `date` field over v2's Edm.DateTime reads its date). + driver.registerExternalObject({ name: fixture.object, fields: fixture.fields, external: { remoteName: ENTITY_SET } }); return { driver }; } -for (const collation of ['ordinal', 'case-insensitive'] as const) { +for (const version of ['4.0', '2.0'] as const) for (const collation of ['ordinal', 'case-insensitive'] as const) { definePluginDriverConformanceSuite( { describe, it, afterAll }, { setup: (fixture) => { const properties: ServiceProperty[] = [ { name: 'id', type: 'Edm.String', nullable: false }, - ...Object.entries(fixture.fields).map(([name, f]) => ({ name, type: EDM[f.type] })), + ...Object.entries(fixture.fields).map(([name, f]) => ({ name, type: EDM[version][f.type] })), ]; - const service = odataService({ entitySet: ENTITY_SET, properties, rows: fixture.rows, collation, serverPageSize: 5 }); - return driverOver(fixture, service.fetch); + const service = odataService({ entitySet: ENTITY_SET, properties, rows: fixture.rows, collation, serverPageSize: 5, version }); + return driverOver(fixture, service.fetch, version); }, - setupUnavailable: (fixture) => driverOver(fixture, async () => { throw new TypeError('fetch failed: connect ECONNREFUSED'); }), + setupUnavailable: (fixture) => + driverOver(fixture, async () => { throw new TypeError('fetch failed: connect ECONNREFUSED'); }, version), writeProbes: [ // The service answers 409 to a second entity under an existing key. { name: 'a create under a key the service already holds', operation: 'create', args: [{ id: '1', a: 'x' }], expect: 'refused' }, { name: 'an update by key the service accepts', operation: 'update', args: ['1', { a: 'changed' }], expect: 'accepted' }, ], }, - `driver-odata through the plugin-driver conformance kit, over a ${collation} service`, + `driver-odata through the plugin-driver conformance kit, over a ${collation} service${version === '2.0' ? ' — OData v2' : ''}`, ); } diff --git a/packages/drivers/driver-odata/src/odata-driver.conformance.test.ts b/packages/drivers/driver-odata/src/odata-driver.conformance.test.ts index 7362fb475a2..314fd52bd2b 100644 --- a/packages/drivers/driver-odata/src/odata-driver.conformance.test.ts +++ b/packages/drivers/driver-odata/src/odata-driver.conformance.test.ts @@ -29,6 +29,11 @@ * loudly"). * - VALUE_ROUNDTRIP: the driver's own `create` then `find`: what was written is * what is read back, type included. + * + * Every suite runs twice more over an OData v2 service (`version: '2.0'` on + * both sides), which declares v2's own types where v4 has others + * (`Edm.DateTime` for `Edm.DateTimeOffset` and `Edm.Date`, `Edm.Time` for + * `Edm.TimeOfDay`) and refuses any v4 spelling the driver might send. */ import { describe, expect, it } from 'vitest'; @@ -62,7 +67,13 @@ import { } from '@objectstack/spec/data'; import { parseODataDriverConfig } from './config.js'; import { ODataDriver } from './odata-driver.js'; -import { CONFORMANCE_ROOT, odataService, type ServiceProperty } from './__fixtures__/odata-service.js'; +import { + CONFORMANCE_ROOT, + odataService, + type EdmType, + type ODataVersion, + type ServiceProperty, +} from './__fixtures__/odata-service.js'; type Row = Record; const SYSTEM = { context: { isSystem: true } }; @@ -70,6 +81,17 @@ const DATASOURCE = 'conformance'; const OBJECT = 'conformance_row'; const ENTITY_SET = 'Rows'; const COLLATIONS = ['ordinal', 'case-insensitive'] as const; +const VERSIONS: ODataVersion[] = ['4.0', '2.0']; +/** The type a v2 service declares where v4 has a type v2 does not (v2's CSDL has no Date, TimeOfDay). */ +const V2_TYPE: Partial> = { + 'Edm.DateTimeOffset': 'Edm.DateTime', + 'Edm.Date': 'Edm.DateTime', + 'Edm.TimeOfDay': 'Edm.Time', +}; +const forVersion = (properties: ServiceProperty[], version: ODataVersion) => + version === '2.0' ? properties.map((p) => ({ ...p, type: V2_TYPE[p.type] ?? p.type })) : properties; +/** The suite name: the v4 names as they were, the v2 runs marked. */ +const named = (name: string, version: ODataVersion) => (version === '2.0' ? `${name} — OData v2` : name); const sortedIds = (rows: Row[]) => rows.map((r) => String(r.id)).sort(); @@ -80,15 +102,20 @@ async function boot(opts: { fields: Record; collation?: (typeof COLLATIONS)[number]; serverPageSize?: number; + version: ODataVersion; }) { const service = odataService({ entitySet: ENTITY_SET, - properties: opts.properties, + properties: forVersion(opts.properties, opts.version), + version: opts.version, rows: opts.rows.map((r) => ({ ...r }) as Row), ...(opts.collation ? { collation: opts.collation } : {}), ...(opts.serverPageSize ? { serverPageSize: opts.serverPageSize } : {}), }); - const driver = new ODataDriver(parseODataDriverConfig({ name: DATASOURCE, serviceRoot: CONFORMANCE_ROOT }), { fetch: service.fetch }); + const driver = new ODataDriver( + parseODataDriverConfig({ name: DATASOURCE, serviceRoot: CONFORMANCE_ROOT, version: opts.version }), + { fetch: service.fetch }, + ); const engine = new ObjectQL(); engine.registerDriver(driver); engine.registerDatasourceDef({ name: DATASOURCE, driver: DATASOURCE, schemaMode: 'external', external: { allowWrites: true } }); @@ -106,247 +133,262 @@ async function boot(opts: { const text = { type: 'text' } as const; const STRING = (name: string, nullable = true): ServiceProperty => ({ name, type: 'Edm.String', nullable }); -describe.each(COLLATIONS)('driver-odata conformance over a %s service', (collation) => { - describe('FILTER_LOGIC_CASES', () => { - const columns = ['a', 'b', 'c', 'd', 'owner', 'status', 'parent_object', 'parent_id']; - const setup = () => boot({ - collation, - properties: [STRING('id', false), ...columns.map((c) => STRING(c))], - rows: FILTER_LOGIC_ROWS, - fields: Object.fromEntries(columns.map((c) => [c, text])), +for (const version of VERSIONS) { + describe.each(COLLATIONS)(named('driver-odata conformance over a %s service', version), (collation) => { + describe('FILTER_LOGIC_CASES', () => { + const columns = ['a', 'b', 'c', 'd', 'owner', 'status', 'parent_object', 'parent_id']; + const setup = () => boot({ + version, + collation, + properties: [STRING('id', false), ...columns.map((c) => STRING(c))], + rows: FILTER_LOGIC_ROWS, + fields: Object.fromEntries(columns.map((c) => [c, text])), + }); + for (const c of FILTER_LOGIC_CASES) { + it(c.name, async () => { + const { engine } = await setup(); + expect(sortedIds(await engine.find(OBJECT, { where: c.filter, ...SYSTEM } as never)), c.note).toEqual([...c.expected]); + }); + } }); - for (const c of FILTER_LOGIC_CASES) { - it(c.name, async () => { - const { engine } = await setup(); - expect(sortedIds(await engine.find(OBJECT, { where: c.filter, ...SYSTEM } as never)), c.note).toEqual([...c.expected]); + + describe('FILTER_TEXT_CASES', () => { + const setup = () => boot({ + version, + collation, + properties: [STRING('id', false), STRING('name'), { name: 'score', type: 'Edm.Int32' }], + rows: FILTER_TEXT_ROWS, + fields: { name: text, score: { type: 'number' } }, }); - } - }); + const isRejection = (c: FilterTextCase): c is FilterTextRejectionCase => c.expectRejection === true; + for (const c of FILTER_TEXT_CASES) { + if (isRejection(c)) { + it(`${c.name} — refused in the ADR-0112 envelope`, async () => { + const { floored } = await setup(); + const err = await floored.find(OBJECT, { where: c.filter }).then(() => undefined, (e: unknown) => e); + expect(err, c.note ?? 'expected a refusal').toMatchObject({ code: c.code, status: 400 }); + for (const mention of c.mustMention) expect((err as Error).message).toContain(mention); + }); + } else { + it(c.name, async () => { + const { floored } = await setup(); + expect(sortedIds(await floored.find(OBJECT, { where: c.filter })), c.note).toEqual([...c.expected]); + }); + } + } + }); - describe('FILTER_TEXT_CASES', () => { - const setup = () => boot({ - collation, - properties: [STRING('id', false), STRING('name'), { name: 'score', type: 'Edm.Int32' }], - rows: FILTER_TEXT_ROWS, - fields: { name: text, score: { type: 'number' } }, + describe('FILTER_COMPARAND_TYPE_CASES', () => { + const setup = () => boot({ + version, + collation, + properties: [ + STRING('id', false), { name: 'qty', type: 'Edm.Int32' }, STRING('label'), + { name: 'active', type: 'Edm.Boolean' }, STRING('note'), + ], + rows: FILTER_COMPARAND_TYPE_ROWS, + fields: { qty: { type: 'number' }, label: text, active: { type: 'boolean' }, note: text }, + }); + for (const c of FILTER_COMPARAND_TYPE_CASES) { + it(`${c.name} (${c.verdict})`, async () => { + const { engine } = await setup(); + const read = engine.find(OBJECT, { where: c.filter(), ...SYSTEM } as never); + if (c.verdict === 'matches') expect(sortedIds(await read), c.note).toEqual([...c.expected]); + else if (c.verdict === 'compiles') await expect(read, c.note).resolves.toBeInstanceOf(Array); + else { + const err = await read.then(() => undefined, (e: unknown) => e); + expect(err, c.note).toMatchObject({ code: c.code, status: 400 }); + for (const mention of c.mustMention) expect((err as Error).message).toContain(mention); + } + }); + } }); - const isRejection = (c: FilterTextCase): c is FilterTextRejectionCase => c.expectRejection === true; - for (const c of FILTER_TEXT_CASES) { - if (isRejection(c)) { - it(`${c.name} — refused in the ADR-0112 envelope`, async () => { - const { floored } = await setup(); - const err = await floored.find(OBJECT, { where: c.filter }).then(() => undefined, (e: unknown) => e); - expect(err, c.note ?? 'expected a refusal').toMatchObject({ code: c.code, status: 400 }); - for (const mention of c.mustMention) expect((err as Error).message).toContain(mention); + + // Not a shared case-set: the shared filter rows negate text, which the driver never pushes + // down. A negation on a number is pushed down, under `not`, to a service whose parser reads + // OData's precedence (`not` binds tighter than `eq`) and refuses a non-Boolean operand. + describe('negation pushed down on a non-text property', () => { + const setup = () => boot({ + version, + collation, + properties: [ + STRING('id', false), { name: 'n', type: 'Edm.Int32' }, { name: 'm', type: 'Edm.Int32', nullable: false }, + ], + rows: [{ id: 'r1', n: 1, m: 1 }, { id: 'r2', n: 2, m: 2 }, { id: 'r3', n: null, m: 3 }], + fields: { n: { type: 'number' }, m: { type: 'number' } }, + }); + const cases: Array<[string, object, string[], string]> = [ + ['$nin of one value, nullable', { n: { $nin: [1] } }, ['r2', 'r3'], 'not (n eq 1) or n eq null'], + ['$nin of two values, nullable', { n: { $nin: [1, 2] } }, ['r3'], 'not (n eq 1 or n eq 2) or n eq null'], + ['$nin of one value, not nullable', { m: { $nin: [1] } }, ['r2', 'r3'], 'not (m eq 1)'], + ['$ne, nullable', { n: { $ne: 1 } }, ['r2', 'r3'], 'n ne 1 or n eq null'], + ]; + for (const [name, where, expected, filter] of cases) { + it(name, async () => { + const { engine, service } = await setup(); + expect(sortedIds(await engine.find(OBJECT, { where, ...SYSTEM } as never))).toEqual(expected); + expect(service.filters.join('\n')).toContain(filter); }); - } else { + } + it('the service refuses `not` over a non-Boolean operand, as a precedence-reading parser does', async () => { + const { service } = await setup(); + const answer = await service.fetch(`${CONFORMANCE_ROOT}Rows?$filter=${encodeURIComponent('not n eq 1')}`, { method: 'GET' }); + expect(answer.status).toBe(400); + }); + }); + + describe('TEMPORAL_CASES', () => { + const setup = () => boot({ + version, + collation, + properties: [ + STRING('id', false), { name: 'at', type: 'Edm.DateTimeOffset' }, { name: 'on', type: 'Edm.Date' }, STRING('why'), + ], + rows: TEMPORAL_ROWS.map((r) => ({ id: r.id, at: r.at, on: r.on, why: r.why })), + fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: text }, + }); + for (const c of TEMPORAL_CASES) { it(c.name, async () => { - const { floored } = await setup(); - expect(sortedIds(await floored.find(OBJECT, { where: c.filter })), c.note).toEqual([...c.expected]); + const { engine } = await setup(); + expect(sortedIds(await engine.find(OBJECT, { where: c.filter, ...SYSTEM } as never)), c.note).toEqual([...c.expected].sort()); }); } - } - }); - - describe('FILTER_COMPARAND_TYPE_CASES', () => { - const setup = () => boot({ - collation, - properties: [ - STRING('id', false), { name: 'qty', type: 'Edm.Int32' }, STRING('label'), - { name: 'active', type: 'Edm.Boolean' }, STRING('note'), - ], - rows: FILTER_COMPARAND_TYPE_ROWS, - fields: { qty: { type: 'number' }, label: text, active: { type: 'boolean' }, note: text }, }); - for (const c of FILTER_COMPARAND_TYPE_CASES) { - it(`${c.name} (${c.verdict})`, async () => { - const { engine } = await setup(); - const read = engine.find(OBJECT, { where: c.filter(), ...SYSTEM } as never); - if (c.verdict === 'matches') expect(sortedIds(await read), c.note).toEqual([...c.expected]); - else if (c.verdict === 'compiles') await expect(read, c.note).resolves.toBeInstanceOf(Array); - else { - const err = await read.then(() => undefined, (e: unknown) => e); - expect(err, c.note).toMatchObject({ code: c.code, status: 400 }); - for (const mention of c.mustMention) expect((err as Error).message).toContain(mention); - } - }); - } - }); - // Not a shared case-set: the shared filter rows negate text, which the driver never pushes - // down. A negation on a number is pushed down, under `not`, to a service whose parser reads - // OData's precedence (`not` binds tighter than `eq`) and refuses a non-Boolean operand. - describe('negation pushed down on a non-text property', () => { - const setup = () => boot({ - collation, - properties: [ - STRING('id', false), { name: 'n', type: 'Edm.Int32' }, { name: 'm', type: 'Edm.Int32', nullable: false }, - ], - rows: [{ id: 'r1', n: 1, m: 1 }, { id: 'r2', n: 2, m: 2 }, { id: 'r3', n: null, m: 3 }], - fields: { n: { type: 'number' }, m: { type: 'number' } }, + describe('TEMPORAL_TIME_CASES', () => { + const setup = () => boot({ + version, + collation, + properties: [STRING('id', false), { name: 'at', type: 'Edm.TimeOfDay' }, STRING('why')], + rows: TEMPORAL_TIME_ROWS.map((r) => ({ id: r.id, at: r.at, why: r.why })), + fields: { at: { type: 'time' }, why: text }, + }); + for (const c of TEMPORAL_TIME_CASES) { + it(c.name, async () => { + const { engine } = await setup(); + expect(sortedIds(await engine.find(OBJECT, { where: c.filter, ...SYSTEM } as never)), c.note).toEqual([...c.expected].sort()); + }); + } }); - const cases: Array<[string, object, string[], string]> = [ - ['$nin of one value, nullable', { n: { $nin: [1] } }, ['r2', 'r3'], 'not (n eq 1) or n eq null'], - ['$nin of two values, nullable', { n: { $nin: [1, 2] } }, ['r3'], 'not (n eq 1 or n eq 2) or n eq null'], - ['$nin of one value, not nullable', { m: { $nin: [1] } }, ['r2', 'r3'], 'not (m eq 1)'], - ['$ne, nullable', { n: { $ne: 1 } }, ['r2', 'r3'], 'n ne 1 or n eq null'], - ]; - for (const [name, where, expected, filter] of cases) { - it(name, async () => { - const { engine, service } = await setup(); - expect(sortedIds(await engine.find(OBJECT, { where, ...SYSTEM } as never))).toEqual(expected); - expect(service.filters.join('\n')).toContain(filter); + + describe('PAGINATION cases', () => { + // A service page of 5 rows, so a walk crosses `@odata.nextLink`. + const setup = () => boot({ + version, + collation, + serverPageSize: 5, + properties: [STRING('id', false), STRING('status'), { name: 'rank', type: 'Edm.Int32' }, STRING('name')], + rows: PAGINATION_ROWS, + fields: { status: text, rank: { type: 'number' }, name: text }, }); - } - it('the service refuses `not` over a non-Boolean operand, as a precedence-reading parser does', async () => { - const { service } = await setup(); - const answer = await service.fetch(`${CONFORMANCE_ROOT}Rows?$filter=${encodeURIComponent('not n eq 1')}`, { method: 'GET' }); - expect(answer.status).toBe(400); + const walk = async (engine: ObjectQL, orderBy: unknown, pageSize: number) => { + const visited: Row[] = []; + for (let offset = 0; offset < PAGINATION_ROWS.length; offset += pageSize) { + visited.push(...await engine.find(OBJECT, { ...(orderBy ? { orderBy } : {}), limit: pageSize, offset, ...SYSTEM } as never)); + } + return visited; + }; + + for (const c of PAGINATION_CASES) { + it(`${c.name}: every row exactly once, in the requested order`, async () => { + const { engine } = await setup(); + const visited = await walk(engine, c.orderBy, c.pageSize); + expect(sortedIds(visited)).toEqual([...PAGINATION_ALL_IDS].sort()); + const whole = await engine.find(OBJECT, { orderBy: c.orderBy, ...SYSTEM } as never); + const keys = (row: Row) => c.orderBy.map(({ field }) => row[field]); + expect(visited.map(keys)).toEqual(whole.map(keys)); + }); + } + for (const c of PAGINATION_UNORDERED_CASES) { + it(`unordered — ${c.name}: every row exactly once`, async () => { + const { engine } = await setup(); + const visited = await walk(engine, undefined, c.pageSize); + expect(visited).toHaveLength(PAGINATION_ALL_IDS.length); + expect(sortedIds(visited)).toEqual([...PAGINATION_ALL_IDS].sort()); + }); + } + for (const c of PAGINATION_ZERO_LIMIT_CASES) { + it(`zero limit — ${c.name}`, async () => { + const { engine } = await setup(); + expect(await engine.find(OBJECT, { ...c.query, ...SYSTEM } as never)).toHaveLength(c.expectedRowCount); + }); + } }); }); - describe('TEMPORAL_CASES', () => { + describe(named('driver-odata conformance — AGGREGATION_CASES (the engine aggregates the floored rows)', version), () => { const setup = () => boot({ - collation, + version, properties: [ - STRING('id', false), { name: 'at', type: 'Edm.DateTimeOffset' }, { name: 'on', type: 'Edm.Date' }, STRING('why'), + STRING('id', false), STRING('region'), STRING('stage'), { name: 'score', type: 'Edm.Int32' }, + { name: 'amount', type: 'Edm.Int32' }, { name: 'flag', type: 'Edm.Boolean' }, ], - rows: TEMPORAL_ROWS.map((r) => ({ id: r.id, at: r.at, on: r.on, why: r.why })), - fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: text }, + rows: AGGREGATION_ROWS, + fields: { region: text, stage: text, score: { type: 'number' }, amount: { type: 'number' }, flag: { type: 'boolean' } }, }); - for (const c of TEMPORAL_CASES) { - it(c.name, async () => { - const { engine } = await setup(); - expect(sortedIds(await engine.find(OBJECT, { where: c.filter, ...SYSTEM } as never)), c.note).toEqual([...c.expected].sort()); - }); - } - }); + const byGroup = (a: { group: string | null }, b: { group: string | null }) => String(a.group).localeCompare(String(b.group)); - describe('TEMPORAL_TIME_CASES', () => { - const setup = () => boot({ - collation, - properties: [STRING('id', false), { name: 'at', type: 'Edm.TimeOfDay' }, STRING('why')], - rows: TEMPORAL_TIME_ROWS.map((r) => ({ id: r.id, at: r.at, why: r.why })), - fields: { at: { type: 'time' }, why: text }, - }); - for (const c of TEMPORAL_TIME_CASES) { + for (const c of AGGREGATION_CASES) { it(c.name, async () => { const { engine } = await setup(); - expect(sortedIds(await engine.find(OBJECT, { where: c.filter, ...SYSTEM } as never)), c.note).toEqual([...c.expected].sort()); + const rows: Row[] = await engine.aggregate(OBJECT, { + aggregations: [{ function: c.function, ...(c.field ? { field: c.field } : {}), alias: 'n' }], + ...(c.groupBy ? { groupBy: [c.groupByAlias ? { field: c.groupBy, alias: c.groupByAlias } : c.groupBy] } : {}), + ...SYSTEM, + } as never); + const groupKey = c.groupByAlias ?? c.groupBy; + const actual = rows.map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: r.n })).sort(byGroup); + expect(actual, c.note ?? c.name).toEqual(c.expected.map((e) => ({ group: e.group, value: e.value })).sort(byGroup)); }); } }); - describe('PAGINATION cases', () => { - // A service page of 5 rows, so a walk crosses `@odata.nextLink`. + describe(named('driver-odata conformance — READABLE_IDS_CASES (refused loudly: $filter has no semi-join)', version), () => { const setup = () => boot({ - collation, - serverPageSize: 5, - properties: [STRING('id', false), STRING('status'), { name: 'rank', type: 'Edm.Int32' }, STRING('name')], - rows: PAGINATION_ROWS, - fields: { status: text, rank: { type: 'number' }, name: text }, + version, + properties: [STRING('id', false), STRING('object_name'), STRING('record_id'), STRING('label'), STRING('organization_id')], + rows: READABLE_IDS_CHILD_ROWS, + fields: { object_name: text, record_id: text, label: text, organization_id: text }, }); - const walk = async (engine: ObjectQL, orderBy: unknown, pageSize: number) => { - const visited: Row[] = []; - for (let offset = 0; offset < PAGINATION_ROWS.length; offset += pageSize) { - visited.push(...await engine.find(OBJECT, { ...(orderBy ? { orderBy } : {}), limit: pageSize, offset, ...SYSTEM } as never)); + for (const c of READABLE_IDS_CASES) { + if (c.verdict === 'matches' && !c.holdsComposedLeaf) { + // The engine's denial answer carries no leaf: an ordinary `where`, answered. + it(`${c.name} — answered`, async () => { + const { floored } = await setup(); + expect(sortedIds(await floored.find(OBJECT, { where: c.where() })), c.note).toEqual([...c.expected]); + }); + continue; } - return visited; - }; - - for (const c of PAGINATION_CASES) { - it(`${c.name}: every row exactly once, in the requested order`, async () => { - const { engine } = await setup(); - const visited = await walk(engine, c.orderBy, c.pageSize); - expect(sortedIds(visited)).toEqual([...PAGINATION_ALL_IDS].sort()); - const whole = await engine.find(OBJECT, { orderBy: c.orderBy, ...SYSTEM } as never); - const keys = (row: Row) => c.orderBy.map(({ field }) => row[field]); - expect(visited.map(keys)).toEqual(whole.map(keys)); - }); - } - for (const c of PAGINATION_UNORDERED_CASES) { - it(`unordered — ${c.name}: every row exactly once`, async () => { - const { engine } = await setup(); - const visited = await walk(engine, undefined, c.pageSize); - expect(visited).toHaveLength(PAGINATION_ALL_IDS.length); - expect(sortedIds(visited)).toEqual([...PAGINATION_ALL_IDS].sort()); + it(`${c.name} — refused by the driver and behind the floor, nothing sent`, async () => { + const { driver, floored, service } = await setup(); + await expect(driver.find(OBJECT, { where: c.where() }), c.note).rejects.toMatchObject(READABLE_IDS_REFUSAL); + await expect(driver.count(OBJECT, { where: c.where() }), c.note).rejects.toMatchObject(READABLE_IDS_REFUSAL); + await expect(floored.find(OBJECT, { where: c.where() }), c.note).rejects.toMatchObject(READABLE_IDS_REFUSAL); + expect(service.filters).toEqual([]); }); } - for (const c of PAGINATION_ZERO_LIMIT_CASES) { - it(`zero limit — ${c.name}`, async () => { - const { engine } = await setup(); - expect(await engine.find(OBJECT, { ...c.query, ...SYSTEM } as never)).toHaveLength(c.expectedRowCount); - }); - } - }); -}); - -describe('driver-odata conformance — AGGREGATION_CASES (the engine aggregates the floored rows)', () => { - const setup = () => boot({ - properties: [ - STRING('id', false), STRING('region'), STRING('stage'), { name: 'score', type: 'Edm.Int32' }, - { name: 'amount', type: 'Edm.Int32' }, { name: 'flag', type: 'Edm.Boolean' }, - ], - rows: AGGREGATION_ROWS, - fields: { region: text, stage: text, score: { type: 'number' }, amount: { type: 'number' }, flag: { type: 'boolean' } }, }); - const byGroup = (a: { group: string | null }, b: { group: string | null }) => String(a.group).localeCompare(String(b.group)); - for (const c of AGGREGATION_CASES) { - it(c.name, async () => { - const { engine } = await setup(); - const rows: Row[] = await engine.aggregate(OBJECT, { - aggregations: [{ function: c.function, ...(c.field ? { field: c.field } : {}), alias: 'n' }], - ...(c.groupBy ? { groupBy: [c.groupByAlias ? { field: c.groupBy, alias: c.groupByAlias } : c.groupBy] } : {}), - ...SYSTEM, - } as never); - const groupKey = c.groupByAlias ?? c.groupBy; - const actual = rows.map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: r.n })).sort(byGroup); - expect(actual, c.note ?? c.name).toEqual(c.expected.map((e) => ({ group: e.group, value: e.value })).sort(byGroup)); - }); - } -}); - -describe('driver-odata conformance — READABLE_IDS_CASES (refused loudly: $filter has no semi-join)', () => { - const setup = () => boot({ - properties: [STRING('id', false), STRING('object_name'), STRING('record_id'), STRING('label'), STRING('organization_id')], - rows: READABLE_IDS_CHILD_ROWS, - fields: { object_name: text, record_id: text, label: text, organization_id: text }, - }); - for (const c of READABLE_IDS_CASES) { - if (c.verdict === 'matches' && !c.holdsComposedLeaf) { - // The engine's denial answer carries no leaf: an ordinary `where`, answered. - it(`${c.name} — answered`, async () => { - const { floored } = await setup(); - expect(sortedIds(await floored.find(OBJECT, { where: c.where() })), c.note).toEqual([...c.expected]); + describe(named('driver-odata conformance — VALUE_ROUNDTRIP_CASES (create, then read back)', version), () => { + // The driver alone: the case-set's field shape is the driver-level one, not an engine object's. + const columns = Object.keys(VALUE_ROUNDTRIP_FIELDS); + for (const c of VALUE_ROUNDTRIP_CASES) { + it(`${c.name} (${c.column})`, async () => { + const service = odataService({ + entitySet: ENTITY_SET, + properties: [STRING('id', false), ...columns.map((name) => STRING(name))], + rows: [], + version, + }); + const driver = new ODataDriver( + parseODataDriverConfig({ name: DATASOURCE, serviceRoot: CONFORMANCE_ROOT, version }), + { fetch: service.fetch }, + ); + driver.registerExternalObject({ name: OBJECT, external: { remoteName: ENTITY_SET }, fields: VALUE_ROUNDTRIP_FIELDS }); + await driver.create(OBJECT, { id: `rt_${c.name}`, label: c.name, [c.column]: c.wrote }); + const [row] = await driver.find(OBJECT, { where: { id: `rt_${c.name}` } }); + expect(valueRoundTripDivergence(c, row?.[c.column]), c.note).toBeNull(); }); - continue; } - it(`${c.name} — refused by the driver and behind the floor, nothing sent`, async () => { - const { driver, floored, service } = await setup(); - await expect(driver.find(OBJECT, { where: c.where() }), c.note).rejects.toMatchObject(READABLE_IDS_REFUSAL); - await expect(driver.count(OBJECT, { where: c.where() }), c.note).rejects.toMatchObject(READABLE_IDS_REFUSAL); - await expect(floored.find(OBJECT, { where: c.where() }), c.note).rejects.toMatchObject(READABLE_IDS_REFUSAL); - expect(service.filters).toEqual([]); - }); - } -}); - -describe('driver-odata conformance — VALUE_ROUNDTRIP_CASES (create, then read back)', () => { - // The driver alone: the case-set's field shape is the driver-level one, not an engine object's. - const columns = Object.keys(VALUE_ROUNDTRIP_FIELDS); - for (const c of VALUE_ROUNDTRIP_CASES) { - it(`${c.name} (${c.column})`, async () => { - const service = odataService({ - entitySet: ENTITY_SET, - properties: [STRING('id', false), ...columns.map((name) => STRING(name))], - rows: [], - }); - const driver = new ODataDriver(parseODataDriverConfig({ name: DATASOURCE, serviceRoot: CONFORMANCE_ROOT }), { fetch: service.fetch }); - driver.registerExternalObject({ name: OBJECT, external: { remoteName: ENTITY_SET }, fields: VALUE_ROUNDTRIP_FIELDS }); - await driver.create(OBJECT, { id: `rt_${c.name}`, label: c.name, [c.column]: c.wrote }); - const [row] = await driver.find(OBJECT, { where: { id: `rt_${c.name}` } }); - expect(valueRoundTripDivergence(c, row?.[c.column]), c.note).toBeNull(); - }); - } -}); + }); +} diff --git a/packages/drivers/driver-odata/src/odata-driver.ts b/packages/drivers/driver-odata/src/odata-driver.ts index 3455f678ae1..a3832d2f452 100644 --- a/packages/drivers/driver-odata/src/odata-driver.ts +++ b/packages/drivers/driver-odata/src/odata-driver.ts @@ -1,8 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `ODataDriver` — a declarative OData v4 datasource driver (epic #27261 Tier B, - * ruling 6486145005). + * `ODataDriver` — a declarative OData datasource driver (epic #27261 Tier B, + * ruling 6486145005), over the v4 dialect or, with `version: '2.0'`, the v2 + * one. The requests below are spelled in v4; `dialect.ts` names what v2 + * spells differently (`$inlinecount`, the `d` envelope, `MERGE`). * * An administrator declares a datasource served by this driver and binds * objects to its entity sets (`object.external.remoteName`); every read and @@ -52,6 +54,7 @@ import { findMisplacedReadableIds, readableIdsDriverRefusal } from '@objectstack import type { DriverQuery, IDataDriver, IntrospectedSchema, IntrospectedTable } from '@objectstack/spec/contracts'; import type { DriverOptions } from '@objectstack/spec/data'; import type { EdmEntitySet, EdmEntityType, EdmProperty, ODataDialect, ODataServiceModel } from './dialect.js'; +import { odataV2Dialect } from './dialect-v2.js'; import { odataV4Dialect } from './dialect-v4.js'; import { translateWhere, type FilterTarget } from './filter.js'; import type { ConnectorCredentialHandle } from '@objectstack/types'; @@ -61,7 +64,7 @@ import type { ODataDriverConfig } from './config.js'; type Row = Record; /** The dialects this package ships, by the config's `version`. */ -const DIALECTS: Record = { '4.0': odataV4Dialect }; +const DIALECTS: Record = { '4.0': odataV4Dialect, '2.0': odataV2Dialect }; /** How many ETags the driver keeps (by object and id) for `If-Match`. */ const ETAG_CACHE_LIMIT = 10_000; @@ -105,6 +108,8 @@ interface ObjectDeclaration { remoteName?: string; /** remote property → local field */ columnMap: Record; + /** local field → its declared type (`date`, `datetime`, …), where the object declares one */ + fieldTypes: Record; } /** An object resolved against the service model. */ @@ -115,6 +120,8 @@ interface Binding extends FilterTarget { keyProperties: EdmProperty[]; /** remote property → local field */ fieldOfProperty: Map; + /** remote property → the declared type of the field it backs, where the object declares one */ + fieldTypeOfProperty: Map; /** `id` names the (single) key, rather than a property the column map named `id`. */ idIsKeyAlias: boolean; } @@ -223,9 +230,16 @@ export class ODataDriver implements IDataDriver { if (typeof local === 'string') columnMap[remote] = local; } } + const fieldTypes: Record = {}; + if (isRecord(schema.fields)) { + for (const [field, definition] of Object.entries(schema.fields)) { + if (isRecord(definition) && typeof definition.type === 'string') fieldTypes[field] = definition.type; + } + } this.declarations.set(schema.name, { ...(typeof external.remoteName === 'string' ? { remoteName: external.remoteName } : {}), columnMap, + fieldTypes, }); this.bindings.delete(schema.name); } @@ -359,12 +373,13 @@ export class ODataDriver implements IDataDriver { const binding = await this.bind(object); const url = this.entityUrl(binding, id); if (!url) return null; + const verb = this.dialect.updateRequest?.() ?? { method: 'PATCH' as const, headers: {} }; const response = await this.transport.request({ - method: 'PATCH', + method: verb.method, url, subject: binding.entitySet.name, body: this.toRemote(binding, data, false), - headers: { Prefer: 'return=representation', ...this.ifMatch(object, id) }, + headers: { Prefer: 'return=representation', ...verb.headers, ...this.ifMatch(object, id) }, accept: [404], }); if (response.status === 404) return null; @@ -407,7 +422,7 @@ export class ODataDriver implements IDataDriver { const cached = this.bindings.get(object); if (cached) return cached; const model = await this.getServiceModel(); - const declaration = this.declarations.get(object) ?? { columnMap: {} }; + const declaration = this.declarations.get(object) ?? { columnMap: {}, fieldTypes: {} }; const setName = declaration.remoteName ?? object; const entitySet = model.entitySets.get(setName); if (!entitySet) { @@ -426,10 +441,13 @@ export class ODataDriver implements IDataDriver { } const fieldOfProperty = new Map(); + const fieldTypeOfProperty = new Map(); const propertyOfField = new Map(); for (const property of entityType.properties) { const field = declaration.columnMap[property.name] ?? property.name; fieldOfProperty.set(property.name, field); + const fieldType = declaration.fieldTypes[field]; + if (fieldType) fieldTypeOfProperty.set(property.name, fieldType); propertyOfField.set(field, property); } // `id` names the key, unless the column map gave the name to a property of its own. @@ -443,6 +461,7 @@ export class ODataDriver implements IDataDriver { entityType, keyProperties, fieldOfProperty, + fieldTypeOfProperty, idIsKeyAlias, propertyOf: (field) => propertyOfField.get(field), }; @@ -477,7 +496,7 @@ export class ODataDriver implements IDataDriver { for (const key of keyProperties) { const raw = parts.find(([name]) => name === key.name)?.[1]; if (raw === undefined) return undefined; - const value = key.type === 'Edm.String' ? unquote(raw) : raw; + const value = this.dialect.keyValue ? this.dialect.keyValue(raw, key.type) : key.type === 'Edm.String' ? unquote(raw) : raw; const literal = value === undefined ? undefined : this.dialect.literal(value, key.type, model); if (literal === undefined) return undefined; spelled.push(`${key.name}=${encodeURIComponent(literal)}`); @@ -498,18 +517,32 @@ export class ODataDriver implements IDataDriver { .join(','); } - private toRow(binding: Binding, entity: Row): Row { + private toRow(binding: Binding, written: Row): Row { + const entity = this.readValues(binding, written); const row: Row = {}; for (const [key, value] of Object.entries(entity)) { if (this.dialect.isAnnotation(key)) continue; row[binding.fieldOfProperty.get(key) ?? key] = value; } row.id = this.idOf(binding, entity); - const etag = this.dialect.etagOf(entity); + const etag = this.dialect.etagOf(written); if (etag) this.rememberEtag(binding.object, row.id as string, etag); return row; } + /** The entity with each declared property's value read through the dialect (as written when it reads none). */ + private readValues(binding: Binding, entity: Row): Row { + const readValue = this.dialect.readValue?.bind(this.dialect); + if (!readValue) return entity; + const read: Row = { ...entity }; + for (const property of binding.entityType.properties) { + if (property.name in read) { + read[property.name] = readValue(read[property.name], property, binding.fieldTypeOfProperty.get(property.name)); + } + } + return read; + } + /** The request body: declared properties only, renamed to the service's names; the key only on create. */ private toRemote(binding: Binding, data: Row, withKey: boolean): Row { const body: Row = {}; @@ -521,7 +554,7 @@ export class ODataDriver implements IDataDriver { const property = binding.propertyOf(field); if (!property) continue; if (keys.has(property.name) && !withKey) continue; - body[property.name] = value; + body[property.name] = this.dialect.writeValue ? this.dialect.writeValue(value, property) : value; } return body; } diff --git a/packages/services/service-datasource/src/__tests__/__fixtures__/northwind-v2-metadata.xml b/packages/services/service-datasource/src/__tests__/__fixtures__/northwind-v2-metadata.xml new file mode 100644 index 00000000000..654c45791d9 --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/__fixtures__/northwind-v2-metadata.xml @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/services/service-datasource/src/__tests__/external-object-draft-odata-metadata.test.ts b/packages/services/service-datasource/src/__tests__/external-object-draft-odata-metadata.test.ts index a1e87a9718d..03f704d571b 100644 --- a/packages/services/service-datasource/src/__tests__/external-object-draft-odata-metadata.test.ts +++ b/packages/services/service-datasource/src/__tests__/external-object-draft-odata-metadata.test.ts @@ -263,3 +263,89 @@ describe('the lookup the draft writes is one the driver can follow', () => { expect(requests).toContain('Categories?$filter=CategoryID eq 1'); }); }); + +/** + * The same import over an OData v2 service (`version: '2.0'`, #28125): its + * `$metadata` is EDMX 1.0, where a navigation is an `Association` and its + * foreign key a `ReferentialConstraint` on the Association, and its dates are + * `Edm.DateTime`. The driver reads both into the shape the draft reads, under + * dialect `odata-2.0`, so the draft comes out as it does over v4. + */ +describe('an OData v2 datasource imports the same way: Associations become lookups, Edm.DateTime a datetime', () => { + const V2_ROOT = 'https://odata.example.test/V2/Northwind/Northwind.svc/'; + const NORTHWIND_V2_METADATA = readFileSync(new URL('./__fixtures__/northwind-v2-metadata.xml', import.meta.url), 'utf8'); + + function serviceOverV2(objects: ObjectLike[] = []) { + const fetch: FetchLike = async (input) => { + const url = decodeURIComponent(input).slice(V2_ROOT.length); + if (url !== '$metadata') throw new Error(`northwind v2 fixture: no answer recorded for GET ${url}`); + return new Response(NORTHWIND_V2_METADATA, { status: 200, headers: { 'Content-Type': 'application/xml' } }); + }; + const driver = new ODataDriver(parseODataDriverConfig({ name: 'erp', serviceRoot: V2_ROOT, version: '2.0' }), { fetch }); + const saved: Array<{ name: string; definition: Record }> = []; + const registry = [...objects]; + const service = new ExternalDatasourceService({ + introspect: () => driver.introspectSchema(), + getDatasource: async (name): Promise => ({ name, schemaMode: 'external' }), + getObject: async (name) => registry.find((o) => o.name === name), + listObjects: async () => registry, + persistObject: async (name, definition) => { + saved.push({ name, definition }); + registry.push(definition as unknown as ObjectLike); + }, + }); + return { service, saved, driver }; + } + + it('reports dialect odata-2.0, which the matrix reads through its odata arm', async () => { + const { driver } = serviceOverV2(); + expect((await driver.introspectSchema()).dialect).toBe('odata-2.0'); + }); + + it('drafts the v4 fields from the v2 types, Edm.DateTime as a datetime', async () => { + const { service } = serviceOverV2(); + const products = await service.generateObjectDraft('erp', 'Products'); + expect(products.definition.fields).toEqual({ + product_id: { type: 'number' }, + product_name: { type: 'text' }, + supplier_id: { type: 'number' }, + category_id: { type: 'number' }, + unit_price: { type: 'number' }, + discontinued: { type: 'boolean' }, + }); + const employees = await service.generateObjectDraft('erp', 'Employees'); + expect(employees.definition.fields).toMatchObject({ birth_date: { type: 'datetime' } }); + const orders = await service.generateObjectDraft('erp', 'Orders'); + expect(orders.definition.fields).toMatchObject({ order_date: { type: 'datetime' }, freight: { type: 'number' } }); + expect(orders.review.filter((r) => !r.remoteType.startsWith('navigation'))).toEqual([]); + }); + + it('turns an Association\'s ReferentialConstraint into the lookup v4 gives, once the target is imported', async () => { + const before = await serviceOverV2().service.generateObjectDraft('erp', 'Products'); + expect(before.review.map((r) => [r.column, r.remoteType])).toEqual([ + ['Category', 'navigation → Categories'], + ['Supplier', 'navigation → Suppliers'], + ]); + const { service } = serviceOverV2([imported('categories', 'Categories')]); + const draft = await service.generateObjectDraft('erp', 'Products'); + expect(draft.definition.fields).toMatchObject({ + category_id: { type: 'lookup', reference: 'categories' }, + supplier_id: { type: 'number' }, + }); + }); + + it('reads the self-association onto the object itself, and a composite-key set\'s lookup onto a single key', async () => { + const employees = await serviceOverV2().service.generateObjectDraft('erp', 'Employees'); + expect(employees.definition.fields).toMatchObject({ reports_to: { type: 'lookup', reference: 'employees' } }); + const lines = await serviceOverV2([imported('products', 'Products')]).service.generateObjectDraft('erp', 'Order_Details'); + expect(lines.definition.fields).toMatchObject({ order_id: { type: 'number' }, product_id: { type: 'lookup', reference: 'products' } }); + }); + + it('passes Gate 2 against the same v2 $metadata, lookups included', async () => { + const { service } = serviceOverV2(); + for (const set of ['Categories', 'Suppliers', 'Products', 'Employees', 'Orders']) await service.importObject('erp', set); + for (const name of ['categories', 'suppliers', 'products', 'employees', 'orders']) { + expect(await service.validateObject(name), name).toEqual({ ok: true, datasource: 'erp', object: name, diffs: [] }); + } + }); +}); diff --git a/packages/spec/src/data/type-compat.test.ts b/packages/spec/src/data/type-compat.test.ts index 3a6a2bd8b91..d1cfa40d5ff 100644 --- a/packages/spec/src/data/type-compat.test.ts +++ b/packages/spec/src/data/type-compat.test.ts @@ -54,6 +54,11 @@ describe('canonicalizeSqlType (ADR-0015 §4.6)', () => { expect(canonicalizeSqlType('Edm.Guid', 'odata')).toBe('uuid'); expect(canonicalizeSqlType('Edm.Binary', 'odata')).toBe('binary'); expect(canonicalizeSqlType('Collection(Edm.String)', 'odata')).toBe('array'); + // The two primitives only OData v2's CSDL declares (#28125). + expect(canonicalizeSqlType('Edm.DateTime', 'odata')).toBe('datetime'); + expect(canonicalizeSqlType('Edm.Time', 'odata')).toBe('time'); + expect(suggestFieldTypeForSqlType('Edm.DateTime', 'odata')).toBe('datetime'); + expect(suggestFieldTypeForSqlType('Edm.Time', 'odata')).toBe('time'); // The vocabulary is the dialect's own: no other dialect reads an EDM name. expect(canonicalizeSqlType('Edm.Int32')).toBe('unknown'); expect(canonicalizeSqlType('Edm.Int32', 'postgres')).toBe('unknown'); diff --git a/packages/spec/src/data/type-compat.ts b/packages/spec/src/data/type-compat.ts index 008464e9dcb..7eb5d410f6e 100644 --- a/packages/spec/src/data/type-compat.ts +++ b/packages/spec/src/data/type-compat.ts @@ -111,7 +111,10 @@ const DIALECT_ALIASES: Partial