Skip to content

Commit 0a4b0fb

Browse files
feat(driver-odata): a declarative OData v4 datasource driver, published as a plugin driver id behind the engine floor (#27796)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiRQcUJw9QVGJfiGu9mWH7zx)_ Fixes #27741 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 新包 `@objectstack/driver-odata`:声明式 OData v4 数据源驱动。服务端插件 `createODataDriverPlugin` 把它发布成插件 driver(服务 `driver.NAME`,默认 `driver.odata`),管理员声明数据源与对象(`external.remoteName` 指实体集、`external.columnMap` 改字段名)即可实时读写 SAP / Dynamics 365 等 OData 服务,不写代码。 - **为什么:** epic #27261 P2(裁决 6486145005,Tier B = 声明式 OData)。按您的指示只做 v4,代码里留了方言接缝,v2 以后另立卡加一个文件即可。 - **怎么保证正确:** 它是插件 driver id,**没有**加进 `DRIVER_VOCABULARY`,引擎的兜底层会在内存里重新应用整条查询。驱动只下推能翻译的部分,并且保证只会让服务端多返回、不会少返回(`$not`、文本排序与否定、`$like` 等都留给引擎)。 - **凭据:** 只走数据源的 `external.credentialsRef`,经宿主的 secret store 在第一次请求时解析;缺引用、缺 store、解析不出都直接拒绝,不会无认证发请求。没有动 spec 的 schema、也没有加 DRIVER_VOCABULARY。 - **两分钟验证:** `pnpm --filter @objectstack/driver-odata test`,预期 6 个文件、425 条全部通过(其中 326 条是 11 组共享驱动一致性用例,在真实引擎兜底层之后、对一个会执行 $filter 的进程内 OData 服务、按大小写敏感与不敏感两种排序规则各跑一遍)。 - **需要你做的:** 本 PR 改动 3691 行,超过 3000 行门槛,按规则需要您在 GitHub 上 APPROVE 一次才能落地;其余由席位处理。 - **额外动到的两处:** `packages/spec/llms.txt` 的包数 72 改 73(新包让 `check:llms-txt` 变红,只改数字);新包加进 changeset 的 `fixed` 组。 ## What changes A new package, `packages/drivers/driver-odata` (`@objectstack/driver-odata`, in the changeset `fixed` group at 18.2.0): - `src/plugin.ts`: `createODataDriverPlugin(options)` parses the config and registers an `ODataDriver` as the kernel service `driver.NAME` in `init` (`driver.odata` by default). This is a **plugin driver id**: it is not added to `DRIVER_VOCABULARY` (`config-registry.zod.ts`), so `needsExternalDriverFloor` holds it behind the engine floor on a `schemaMode: 'external'` datasource. No built-in id is argued for. - `src/config.ts`: the driver's own config contract, a strict zod schema parsed when the plugin is built: `name`, `serviceRoot` (http or https; refused when it carries userinfo or a query string), `version` (`4.0` only), `auth` (`none`, `bearer`, or `basic` with `username`), `pageSize`, `requestTimeoutMs`. No credential is a config key. - `src/odata-driver.ts`: the `IDataDriver`. - Reads: `find` sends `$filter`, `$orderby`, `$select`, `$top`, `$skip` and follows `@odata.nextLink` until the asked number of rows; a paged read appends the entity key to `$orderby` so pages partition the result. `count` sends `$count=true&$top=0` only when the whole `where` was translated, and refuses (status 400) otherwise. `findOne` is `find` with `limit: 1`. - Writes: `POST`, `PATCH` and `DELETE` by key, `If-Match` with the `@odata.etag` of the row's last read (bounded cache), only properties the entity type declares sent under their remote names (the engine's `id`, `updated_at` and other stamps are dropped). `PATCH`/`DELETE` answering 404 return `null` / `false`, the contract's not-found. `upsert`, the bulk verbs, transactions, `execute` and `dropTable` are refused by name with status 400. - Rows: annotations removed, properties renamed through `external.columnMap`, `id` set to the key (the key predicate body for a composite key). `registerExternalObject` records `remoteName` and `columnMap`; the engine's binding pass drives it. - A `ReadableIds` leaf (ADR-0139 D3, a read-scope semi-join) in `where` is refused by the driver itself with `readableIdsDriverRefusal` before any request: `$filter` cannot spell it, and the translator's usual answer to an unspellable predicate (leave it out) would read a read scope as no restriction. - `introspectSchema`: entity sets as tables, EDM type names as column types, the key as the primary key, and single-valued navigation properties (with their bound entity set) as lookup candidates for the `$metadata` import card. - `supports` is `{ transactionsUnsupported: true }` and makes no pushdown claim, because `DriverCapabilities` has none to make. - `src/filter.ts`: the `where` to `$filter` translator. Every translation selects a superset of the engine's reading. `$and` drops an untranslatable member; `$or` with one is dropped whole; `$not` is never pushed down; `$ne` / `$nin` on a nullable property carry `or P eq null`; text gets only `eq`, `in`, `contains`, `startswith`, `endswith` and ASCII `$icontains`; a comparand the property's EDM type cannot spell is left out. The result says whether the translation was complete. - `src/transport.ts`: HTTP with the auth header, a per-request timeout, no redirect following, and same-origin `nextLink` / `Location` only (a cross-origin link is refused so the credential is never sent elsewhere). A refused response throws `ODataRequestError` with the HTTP `status` the floor reads and the service's own `error.message`; no message carries the URL or a header. A 401 resolves the credential once more and retries once. - `src/dialect.ts`, `src/dialect-v4.ts`: **the dialect seam.** Request headers, the collection and entity envelope, the ETag, the error body, the `$metadata` reader (CSDL 4.x with inheritance and namespace aliases resolved; EDMX 1.0 refused by name), literal spellings, string functions, set membership (an `or` chain, which 4.0 services answer) and the count option all go through one `ODataDialect`. Only `odataV4Dialect` ships; a v2 dialect is a new file. - `packages/spec/llms.txt`: the package table's count, 72 to 73 (`check:llms-txt` reds on a new published package; only the two numbers change). - `.changeset/config.json`: the package joins the `fixed` group. `pnpm-lock.yaml`: the new importer (`fast-xml-parser` 5.10.1 was already in the lockfile). - `.changeset/27741-driver-odata.md`: `@objectstack/driver-odata` and `@objectstack/spec` (for `llms.txt`) patch. ## Credentials The secret is the datasource's `external.credentialsRef`. The plugin reads the declared definition from the engine (`listDatasourceDefs()`) and dereferences the handle through the host's datasource secret store (`options.secrets`, the read half of the `SecretBinder` the host already gives `DatasourceAdminServicePlugin`). It does this at the first request, not at `connect()`, so it never reads the definition registry while the boot is still filling it. It fails closed on: a scheme that needs a credential with no `credentialsRef`, no secret store, a handle that does not resolve, an undeclared datasource, and a `credentialsRef` declared under scheme `none`. Each refuses the request before anything is sent; the floor answers 503 and logs the cause. ## Tests Six files, 425 cases. The first five run against a recorded TripPin-shaped service (`src/__fixtures__/trippin-metadata.xml` and `recorded-service.ts`: each test names the exact requests and answers them from recordings; an unrecorded request fails with the URL spelled out): - `filter.test.ts`: the pushdown translation table, 54 rows (what translates, what widens, the complete flag). - `dialect-v4.test.ts`: the CSDL reader (sets, keys, types, collections, inheritance, aliases, navigation and constraints, v2 refusal) and the envelope. - `odata-driver.test.ts`: reads, paging and nextLink, key ordering, `$select`/`$skip`, cross-origin refusal, `$count`, refused reads, writes with `If-Match`, 204 and 404 handling, refused verbs, composite keys, introspection, bearer and 401 re-resolution. - `odata-driver.floor.test.ts`: the driver behind the **real engine** (`ObjectQL`, objects bound through `engine.syncSchemas()`): an untranslatable predicate (`$like`) is applied in memory over the wider answer; `count` reads through the floor; a 400 read is `ExternalReadRefusedError` `INVALID_QUERY` / 400 with the service's reason; a 400 write is `ExternalWriteRefusedError` `EXTERNAL_WRITE_REFUSED` / 422; a 503 is `DatasourceUnavailableError` with the text withheld. - `plugin.test.ts`: the service name, config refusals, bearer and basic from the resolved `credentialsRef`, and the five fail-closed cases. - `odata-driver.conformance.test.ts`: **all eleven shared driver case-sets** (`check:driver-conformance`: 66 covered cells, 0 DEBT, 0 EXEMPT), 326 cases, through the real engine and its floor over an in-process OData v4 service (`__fixtures__/odata-service.ts`) that evaluates the `$filter` the driver writes and answers 400 to anything outside the grammar it reads. The read case-sets run twice, over an ordinal and a case-insensitive service, so the "never narrower" rule is checked against a collation that widens. FILTER_LOGIC, FILTER_COMPARAND_TYPE, TEMPORAL and TEMPORAL_TIME go through `engine.find`; PAGINATION (with server-driven paging at 5 rows) through paged `engine.find` walks; AGGREGATION through `engine.aggregate` (the floor hides `aggregate`); FILTER_TEXT through the floored driver, beneath the engine's declared-type door, which would refuse the case-set's text-operator-on-a-number rows before any driver is asked; READABLE_IDS is refused in the `READABLE_IDS_REFUSAL` envelope by the driver and by the floor, with nothing sent (the one leaf-free case, the engine's `$in: []` denial, is answered); VALUE_ROUNDTRIP through the driver's own `create` then `find`. The vitest config aliases `@objectstack/core`, `objectql`, `spec` and `types` to source. <details><summary>Ablation (one-shot, not kept)</summary> In `src/filter.ts`, the nullable `$ne` arm was replaced by the bare `ne` (anchor count 1, disjunction count 1 to 0). `vitest run src/filter.test.ts`: 1 failed, 53 passed; the failure was `$ne on a nullable property`. Restored with `git checkout HEAD`; the blob equals HEAD and `git diff HEAD` is empty. Tests import the source, so no dist rebuild was involved. </details> ## Acceptance notes - **The driver id and the datasource name.** The engine routes a datasource to a driver by `driver.name`, so one plugin instance serves one datasource, named by the plugin's `name`; the README follows the virtual-objects guide and keeps the datasource `name`, its `driver` and the service suffix equal. Two OData services are two plugin instances. - **No connect-time check.** `connect()` makes no request: the credential needs the datasource definition, which may not be registered yet at that point. `$metadata` is read at the first request and cached; a failed read is retried by the next one. `checkHealth()` asks for the service document. - **No `$batch`.** The bulk verbs are refused rather than looped, since a loop would claim an atomicity the transport does not give. A `$batch` arm is a follow-up, not filed. - **Collation.** Text equality and `contains` pushed down to a case- or accent-insensitive service only widen the answer, and the floor narrows it back. Text ordering and negation are not pushed down for that reason. - **Size.** 3691 changed lines (a new package with its fixtures and a 326-case conformance suite), over the 3000-line threshold, so the PR lands only with an authorized APPROVE. The LICENSE copy is 201 of those lines. - **Paged reads under the floor carry `$orderby` on the key.** The floor always sends a limit, so the service is asked for a key-ordered read. This is what makes `find`'s paging contract hold when the driver is called directly; under the floor it costs nothing. ## Verification Gates were derived with `node scripts/pm/dispatch-gates.mjs --commands` (no paths) on the merged head bcc0a1a and reconciled with `--ran`: "77 derived famil(ies) accounted for — 77 run, 0 NOT-MEASURED (a DERIVED zero — all 77 recorded an exit code and none of them is 3)". All 77 exited 0. On the first pass (head 02401a7) two were red and are fixed here: `check:llms-txt` (the package count) and `check:driver-conformance` (a new driver must run every shared case-set). `pnpm --filter @objectstack/driver-odata test`: 6 files, 425 tests passed; `typecheck` exit 0; `eslint packages/drivers/driver-odata` exit 0. The built CJS and ESM entries load (`require` and `import` of `dist/`). CI check-runs on this PR carry every family CI runs; they are cited in the os-dev report, not re-run locally. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2gRrVctbMWaHbABDch)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b8bcfe commit 0a4b0fb

26 files changed

Lines changed: 3746 additions & 2 deletions

‎.changeset/27741-driver-odata.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/driver-odata': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(driver-odata): a new package, `@objectstack/driver-odata`, serves an OData v4 service's entity sets as external objects through a plugin driver, with no code per entity set
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A server plugin, `createODataDriverPlugin({ name, serviceRoot, auth, pageSize, requestTimeoutMs, secrets })`, publishes an `ODataDriver` as the kernel service `driver.NAME` (`driver.odata` by default). The datasource it serves is declared as any plugin-driver datasource is (its `name` and `driver` both the plugin's `name`, `schemaMode: 'external'`), and each object binds an entity set through `external.remoteName`, renaming properties through `external.columnMap`.
12+
13+
- **A plugin driver id, not a built-in one.** The id is not added to the spec's driver vocabulary, so the engine floors the driver: what the driver does not narrow, the engine applies in memory under its 1000-row cap. `supports` makes no pushdown claim.
14+
- **Reads.** `find` sends `$filter`, `$orderby`, `$select`, `$top` and `$skip` and follows `@odata.nextLink` (same origin only). `$filter` carries comparisons, `$in`, `$between`, `$null`, `contains` / `startswith` / `endswith` / case-folded `contains`, under `and` / `or`; anything else (`$not`, `$like`, text ordering and negation, field references, collection properties) is left to the engine, so the service is only ever asked a wider question. A paged read is ordered by the entity key last. `count` sends `$count=true` when the whole `where` was translated and refuses otherwise.
15+
- **Writes**, under the datasource's `external.allowWrites` and the object's `external.writable`: `POST`, `PATCH` and `DELETE` by key, with `If-Match` carrying the `@odata.etag` of the row's last read. Only properties the entity type declares are sent. A refused write reaches the caller as `422 EXTERNAL_WRITE_REFUSED` with the service's message; `upsert`, the bulk verbs and transactions are refused by name.
16+
- **Keys and schema.** The key comes from `$metadata` (CSDL 4.0, inheritance and namespace aliases resolved); a row's `id` is the key's value, or the key predicate body for a composite key. `introspectSchema` lists entity sets, EDM-typed properties, keys and single-valued navigation properties as lookup candidates.
17+
- **Credentials.** The secret is the datasource's `external.credentialsRef`, dereferenced through the host's datasource secret store (`options.secrets`, the same binder the datasource admin plugin takes) at the first request, and again once after a 401. `bearer` and `basic` are supported; a missing reference, store or secret refuses the request rather than sending it unauthenticated. The config itself refuses a `serviceRoot` that carries credentials.
18+
- **Read scopes.** A `ReadableIds` leaf (a read-scope semi-join) is refused by the driver itself, in the shared refusal envelope, before anything is sent: `$filter` cannot spell it, and leaving it out would read it as no restriction.
19+
- **Conformance.** The package runs all eleven shared driver case-sets, behind the real engine floor, over an in-process OData service that evaluates the `$filter` the driver writes, under an ordinal and a case-insensitive collation.
20+
- **`@objectstack/spec`:** `llms.txt` counts the new package (73 published).
21+
- **The dialect seam.** Headers, the response envelope, the `$metadata` reader, literal and function spellings and the count option go through one `ODataDialect`; only `odataV4Dialect` ships. A v2 dialect is a new file implementing it.

‎.changeset/config.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@objectstack/driver-mongodb",
3737
"@objectstack/driver-sqlite-wasm",
3838
"@objectstack/driver-turso",
39+
"@objectstack/driver-odata",
3940
"@objectstack/plugin-approvals",
4041
"@objectstack/plugin-audit",
4142
"@objectstack/plugin-auth",
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
Apache License
2+
Version 2.0, January 2004
3+
http://www.apache.org/licenses/
4+
5+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6+
7+
1. Definitions.
8+
9+
"License" shall mean the terms and conditions for use, reproduction,
10+
and distribution as defined by Sections 1 through 9 of this document.
11+
12+
"Licensor" shall mean the copyright owner or entity authorized by
13+
the copyright owner that is granting the License.
14+
15+
"Legal Entity" shall mean the union of the acting entity and all
16+
other entities that control, are controlled by, or are under common
17+
control with that entity. For the purposes of this definition,
18+
"control" means (i) the power, direct or indirect, to cause the
19+
direction or management of such entity, whether by contract or
20+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
21+
outstanding shares, or (iii) beneficial ownership of such entity.
22+
23+
"You" (or "Your") shall mean an individual or Legal Entity
24+
exercising permissions granted by this License.
25+
26+
"Source" form shall mean the preferred form for making modifications,
27+
including but not limited to software source code, documentation
28+
source, and configuration files.
29+
30+
"Object" form shall mean any form resulting from mechanical
31+
transformation or translation of a Source form, including but
32+
not limited to compiled object code, generated documentation,
33+
and conversions to other media types.
34+
35+
"Work" shall mean the work of authorship, whether in Source or
36+
Object form, made available under the License, as indicated by a
37+
copyright notice that is included in or attached to the work
38+
(an example is provided in the Appendix below).
39+
40+
"Derivative Works" shall mean any work, whether in Source or Object
41+
form, that is based on (or derived from) the Work and for which the
42+
editorial revisions, annotations, elaborations, or other modifications
43+
represent, as a whole, an original work of authorship. For the purposes
44+
of this License, Derivative Works shall not include works that remain
45+
separable from, or merely link (or bind by name) to the interfaces of,
46+
the Work and Derivative Works thereof.
47+
48+
"Contribution" shall mean any work of authorship, including
49+
the original version of the Work and any modifications or additions
50+
to that Work or Derivative Works thereof, that is intentionally
51+
submitted to Licensor for inclusion in the Work by the copyright owner
52+
or by an individual or Legal Entity authorized to submit on behalf of
53+
the copyright owner. For the purposes of this definition, "submitted"
54+
means any form of electronic, verbal, or written communication sent
55+
to the Licensor or its representatives, including but not limited to
56+
communication on electronic mailing lists, source code control systems,
57+
and issue tracking systems that are managed by, or on behalf of, the
58+
Licensor for the purpose of discussing and improving the Work, but
59+
excluding communication that is conspicuously marked or otherwise
60+
designated in writing by the copyright owner as "Not a Contribution."
61+
62+
"Contributor" shall mean Licensor and any individual or Legal Entity
63+
on behalf of whom a Contribution has been received by Licensor and
64+
subsequently incorporated within the Work.
65+
66+
2. Grant of Copyright License. Subject to the terms and conditions of
67+
this License, each Contributor hereby grants to You a perpetual,
68+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69+
copyright license to reproduce, prepare Derivative Works of,
70+
publicly display, publicly perform, sublicense, and distribute the
71+
Work and such Derivative Works in Source or Object form.
72+
73+
3. Grant of Patent License. Subject to the terms and conditions of
74+
this License, each Contributor hereby grants to You a perpetual,
75+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76+
(except as stated in this section) patent license to make, have made,
77+
use, offer to sell, sell, import, and otherwise transfer the Work,
78+
where such license applies only to those patent claims licensable
79+
by such Contributor that are necessarily infringed by their
80+
Contribution(s) alone or by combination of their Contribution(s)
81+
with the Work to which such Contribution(s) was submitted. If You
82+
institute patent litigation against any entity (including a
83+
cross-claim or counterclaim in a lawsuit) alleging that the Work
84+
or a Contribution incorporated within the Work constitutes direct
85+
or contributory patent infringement, then any patent licenses
86+
granted to You under this License for that Work shall terminate
87+
as of the date such litigation is filed.
88+
89+
4. Redistribution. You may reproduce and distribute copies of the
90+
Work or Derivative Works thereof in any medium, with or without
91+
modifications, and in Source or Object form, provided that You
92+
meet the following conditions:
93+
94+
(a) You must give any other recipients of the Work or
95+
Derivative Works a copy of this License; and
96+
97+
(b) You must cause any modified files to carry prominent notices
98+
stating that You changed the files; and
99+
100+
(c) You must retain, in the Source form of any Derivative Works
101+
that You distribute, all copyright, patent, trademark, and
102+
attribution notices from the Source form of the Work,
103+
excluding those notices that do not pertain to any part of
104+
the Derivative Works; and
105+
106+
(d) If the Work includes a "NOTICE" text file as part of its
107+
distribution, then any Derivative Works that You distribute
108+
must include a readable copy of the attribution notices
109+
contained within such NOTICE file, excluding those notices
110+
that do not pertain to any part of the Derivative Works,
111+
in at least one of the following places: within a NOTICE
112+
text file distributed as part of the Derivative Works; within
113+
the Source form or documentation, if provided along with
114+
the Derivative Works; or, within a display generated by the
115+
Derivative Works, if and wherever such third-party notices
116+
normally appear. The contents of the NOTICE file are for
117+
informational purposes only and do not modify the License.
118+
You may add Your own attribution notices within Derivative
119+
Works that You distribute, alongside or as an addendum to
120+
the NOTICE text from the Work, provided that such additional
121+
attribution notices cannot be construed as modifying the
122+
License.
123+
124+
You may add Your own copyright statement to Your modifications and
125+
may provide additional or different license terms and conditions
126+
for use, reproduction, or distribution of Your modifications, or
127+
for any such Derivative Works as a whole, provided Your use,
128+
reproduction, and distribution of the Work otherwise complies with
129+
the conditions stated in this License.
130+
131+
5. Submission of Contributions. Unless You explicitly state otherwise,
132+
any Contribution intentionally submitted for inclusion in the Work
133+
by You to the Licensor shall be under the terms and conditions of
134+
this License, without any additional terms or conditions.
135+
Notwithstanding the above, nothing herein shall supersede or modify
136+
the terms of any separate license agreement you may have executed
137+
with Licensor regarding such Contributions.
138+
139+
6. Trademarks. This License does not grant permission to use the trade
140+
names, trademarks, service marks, or product names of the Licensor,
141+
except as required for reasonable and customary use in describing the
142+
origin of the Work and reproducing the content of the NOTICE file.
143+
144+
7. Disclaimer of Warranty. Unless required by applicable law or
145+
agreed to in writing, Licensor provides the Work (and each
146+
Contributor provides its Contributions) on an "AS IS" BASIS,
147+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148+
implied, including, without limitation, any warranties or conditions
149+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150+
PARTICULAR PURPOSE. You are solely responsible for determining the
151+
appropriateness of using or redistributing the Work and assume any
152+
risks associated with Your exercise of permissions under this License.
153+
154+
8. Limitation of Liability. In no event and under no legal theory,
155+
whether in tort (including negligence), contract, or otherwise,
156+
unless required by applicable law (such as deliberate and grossly
157+
negligent acts) or agreed to in writing, shall any Contributor be
158+
liable to You for damages, including any direct, indirect, special,
159+
incidental, or consequential damages of any character arising as a
160+
result of this License or out of the use or inability to use the
161+
Work (including but not limited to damages for loss of goodwill,
162+
work stoppage, computer failure or malfunction, or any and all
163+
other commercial damages or losses), even if such Contributor
164+
has been advised of the possibility of such damages.
165+
166+
9. Accepting Warranty or Additional Liability. While redistributing
167+
the Work or Derivative Works thereof, You may choose to offer,
168+
and charge a fee for, acceptance of support, warranty, indemnity,
169+
or other liability obligations and/or rights consistent with this
170+
License. However, in accepting such obligations, You may act only
171+
on Your own behalf and on Your sole responsibility, not on behalf
172+
of any other Contributor, and only if You agree to indemnify,
173+
defend, and hold each Contributor harmless for any liability
174+
incurred by, or claims asserted against, such Contributor by reason
175+
of your accepting any such warranty or additional liability.
176+
177+
END OF TERMS AND CONDITIONS
178+
179+
APPENDIX: How to apply the Apache License to your work.
180+
181+
To apply the Apache License to your work, attach the following
182+
boilerplate notice, with the fields enclosed by brackets "[]"
183+
replaced with your own identifying information. (Don't include
184+
the brackets!) The text should be enclosed in the appropriate
185+
comment syntax for the file format. We also recommend that a
186+
file or class name and description of purpose be included on the
187+
same "printed page" as the copyright notice for easier
188+
identification within third-party archives.
189+
190+
Copyright 2026 ObjectStack
191+
192+
Licensed under the Apache License, Version 2.0 (the "License");
193+
you may not use this file except in compliance with the License.
194+
You may obtain a copy of the License at
195+
196+
http://www.apache.org/licenses/LICENSE-2.0
197+
198+
Unless required by applicable law or agreed to in writing, software
199+
distributed under the License is distributed on an "AS IS" BASIS,
200+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201+
See the License for the specific language governing permissions and
202+
limitations under the License.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# @objectstack/driver-odata
2+
3+
An OData v4 datasource driver for ObjectStack. Declare a datasource over an OData
4+
service (SAP S/4HANA, Dynamics 365, Dataverse, any CSDL 4.0 service), bind objects to
5+
its entity sets, and every view, form, permission and hook works on them, live. No row
6+
is stored on the platform side, and no code is written per entity set.
7+
8+
The driver is a **plugin driver**: a server plugin publishes it as the service
9+
`driver.<name>`. Its id is not a built-in one, so the engine holds it behind its
10+
external-driver floor and re-applies every query to the rows the service returns. The
11+
driver pushes down what it can translate and only ever asks the service a wider
12+
question; see [Virtual objects](https://objectstack.ai/docs/data-modeling/virtual-objects).
13+
14+
## Usage
15+
16+
```typescript
17+
import { defineStack } from '@objectstack/spec';
18+
import { createODataDriverPlugin } from '@objectstack/driver-odata';
19+
20+
export const plugins = [
21+
createODataDriverPlugin({
22+
name: 'erp', // the datasource it serves
23+
serviceRoot: 'https://erp.example.com/odata/v4/sales/',
24+
auth: { scheme: 'bearer' }, // or { scheme: 'basic', username: 'svc_reader' }
25+
secrets: datasourceSecretBinder, // the host's datasource secret store
26+
}),
27+
];
28+
29+
export default defineStack({
30+
manifest: { id: 'com.example.sales', name: 'Sales', version: '1.0.0', type: 'app' },
31+
datasources: [{
32+
name: 'erp',
33+
driver: 'erp',
34+
schemaMode: 'external',
35+
config: {},
36+
external: {
37+
allowWrites: true,
38+
credentialsRef: 'sys_secret:sec_erp', // the bearer token / basic password
39+
validation: { onMismatch: 'warn', checkOnBoot: false },
40+
},
41+
}],
42+
objects: [{
43+
name: 'sales_order',
44+
label: 'Sales order',
45+
datasource: 'erp',
46+
sharingModel: 'public_read_write',
47+
external: {
48+
remoteName: 'SalesOrders', // the entity set
49+
writable: true,
50+
columnMap: { SalesOrderID: 'order_number', CustomerName: 'customer_name', NetAmount: 'net_amount' },
51+
},
52+
fields: {
53+
order_number: { type: 'text', label: 'Order' },
54+
customer_name: { type: 'text', label: 'Customer' },
55+
net_amount: { type: 'number', label: 'Net amount' },
56+
},
57+
}],
58+
});
59+
```
60+
61+
## Config
62+
63+
| Key | Default | Meaning |
64+
| :--- | :--- | :--- |
65+
| `name` | `odata` | The datasource name, the driver name and its `driver.<name>` service suffix. |
66+
| `serviceRoot` | — | The service root URL (http or https, no credentials, no query). |
67+
| `version` | `4.0` | The protocol version. Only the v4 dialect ships. |
68+
| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. |
69+
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). |
70+
| `requestTimeoutMs` | `30000` | Per-request timeout. |
71+
72+
## What is pushed down
73+
74+
`$filter`: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$null`,
75+
`$contains`, `$startsWith`, `$endsWith` and `$icontains` (ASCII comparands), under `$and`
76+
and `$or`. Text ordering and negation, `$not`, `$like`, `$exists`, field references and
77+
collection-valued properties are left to the engine. Also `$orderby`, `$select`, `$top`,
78+
`$skip`, `@odata.nextLink` paging and `$count=true`.
79+
80+
## Writes
81+
82+
`create`, `update` and `delete` by key, with `If-Match` from the row's last read. A
83+
refusal by the service answers `422 EXTERNAL_WRITE_REFUSED` with its message. `upsert`,
84+
the bulk verbs and transactions are refused by name.

0 commit comments

Comments
 (0)