Repository navigation
feat(driver-odata): a declarative OData v4 datasource driver, published as a plugin driver id behind the engine floor - #27796
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2gRrVctbMWaHbABDch
…gine floor Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2gRrVctbMWaHbABDch
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2gRrVctbMWaHbABDch
…s in the floor suite Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2gRrVctbMWaHbABDch
…d the engine floor; refuse ReadableIds leaves Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2gRrVctbMWaHbABDch
📓 Docs Drift CheckThis PR changes 2 package(s): 49 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 167 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5958e95120f576f401e358cc206d59d9528e7911 && git checkout 5958e95120f576f401e358cc206d59d9528e7911
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3cc4563e1e0cead21e76d3b09da732499e771ca7 7f91132b87090f7c0bd8902f0fe17808ca63a6e0 && git checkout -B drift-repro 3cc4563e1e0cead21e76d3b09da732499e771ca7 && git merge --no-ff 7f91132b87090f7c0bd8902f0fe17808ca63a6e0
node scripts/docs-audit/affected-docs.mjs --json 3cc4563e1e0cead21e76d3b09da732499e771ca7
|
`not` binds tighter than `eq` and `or` in OData, so `not P eq 1` reads as `(not P) eq 1`, which a service refuses, and the nullable form's `or P eq null` fell under the `not` and dropped the null rows. The translator now writes `not (list)`, the v4 dialect's in-list is a bare or-chain that the translator groups wherever it is an operand, and the fixture service parses `not` at OData's precedence and refuses a non-Boolean operand, so a number negation pushed down is exercised end to end. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2gRrVctbMWaHbABDch
|
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV 维护者速读
席位意见:ACCEPT。席位在 席位核对
|
Requested by 田騏 · project thread
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 服务,不写代码。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 服务、按大小写敏感与不敏感两种排序规则各跑一遍)。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 changesetfixedgroup at 18.2.0):src/plugin.ts:createODataDriverPlugin(options)parses the config and registers anODataDriveras the kernel servicedriver.NAMEininit(driver.odataby default). This is a plugin driver id: it is not added toDRIVER_VOCABULARY(config-registry.zod.ts), soneedsExternalDriverFloorholds it behind the engine floor on aschemaMode: '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.0only),auth(none,bearer, orbasicwithusername),pageSize,requestTimeoutMs. No credential is a config key.src/odata-driver.ts: theIDataDriver.findsends$filter,$orderby,$select,$top,$skipand follows@odata.nextLinkuntil the asked number of rows; a paged read appends the entity key to$orderbyso pages partition the result.countsends$count=true&$top=0only when the wholewherewas translated, and refuses (status 400) otherwise.findOneisfindwithlimit: 1.POST,PATCHandDELETEby key,If-Matchwith the@odata.etagof the row's last read (bounded cache), only properties the entity type declares sent under their remote names (the engine'sid,updated_atand other stamps are dropped).PATCH/DELETEanswering 404 returnnull/false, the contract's not-found.upsert, the bulk verbs, transactions,executeanddropTableare refused by name with status 400.external.columnMap,idset to the key (the key predicate body for a composite key).registerExternalObjectrecordsremoteNameandcolumnMap; the engine's binding pass drives it.ReadableIdsleaf (ADR-0139 D3, a read-scope semi-join) inwhereis refused by the driver itself withreadableIdsDriverRefusalbefore any request:$filtercannot 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$metadataimport card.supportsis{ transactionsUnsupported: true }and makes no pushdown claim, becauseDriverCapabilitieshas none to make.src/filter.ts: thewhereto$filtertranslator. Every translation selects a superset of the engine's reading.$anddrops an untranslatable member;$orwith one is dropped whole;$notis never pushed down;$ne/$ninon a nullable property carryor P eq null; text gets onlyeq,in,contains,startswith,endswithand 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-originnextLink/Locationonly (a cross-origin link is refused so the credential is never sent elsewhere). A refused response throwsODataRequestErrorwith the HTTPstatusthe floor reads and the service's ownerror.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$metadatareader (CSDL 4.x with inheritance and namespace aliases resolved; EDMX 1.0 refused by name), literal spellings, string functions, set membership (anorchain, which 4.0 services answer) and the count option all go through oneODataDialect. OnlyodataV4Dialectships; a v2 dialect is a new file.packages/spec/llms.txt: the package table's count, 72 to 73 (check:llms-txtreds on a new published package; only the two numbers change)..changeset/config.json: the package joins thefixedgroup.pnpm-lock.yaml: the new importer (fast-xml-parser5.10.1 was already in the lockfile)..changeset/27741-driver-odata.md:@objectstack/driver-odataand@objectstack/spec(forllms.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 theSecretBinderthe host already givesDatasourceAdminServicePlugin). It does this at the first request, not atconnect(), 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 nocredentialsRef, no secret store, a handle that does not resolve, an undeclared datasource, and acredentialsRefdeclared under schemenone. 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.xmlandrecorded-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 withIf-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 throughengine.syncSchemas()): an untranslatable predicate ($like) is applied in memory over the wider answer;countreads through the floor; a 400 read isExternalReadRefusedErrorINVALID_QUERY/ 400 with the service's reason; a 400 write isExternalWriteRefusedErrorEXTERNAL_WRITE_REFUSED/ 422; a 503 isDatasourceUnavailableErrorwith the text withheld.plugin.test.ts: the service name, config refusals, bearer and basic from the resolvedcredentialsRef, 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$filterthe 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 throughengine.find; PAGINATION (with server-driven paging at 5 rows) through pagedengine.findwalks; AGGREGATION throughengine.aggregate(the floor hidesaggregate); 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 theREADABLE_IDS_REFUSALenvelope 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 owncreatethenfind.The vitest config aliases
@objectstack/core,objectql,specandtypesto source.Ablation (one-shot, not kept)
In
src/filter.ts, the nullable$nearm was replaced by the barene(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 withgit checkout HEAD; the blob equals HEAD andgit diff HEADis empty. Tests import the source, so no dist rebuild was involved.Acceptance notes
driver.name, so one plugin instance serves one datasource, named by the plugin'sname; the README follows the virtual-objects guide and keeps the datasourcename, itsdriverand the service suffix equal. Two OData services are two plugin instances.connect()makes no request: the credential needs the datasource definition, which may not be registered yet at that point.$metadatais read at the first request and cached; a failed read is retried by the next one.checkHealth()asks for the service document.$batch. The bulk verbs are refused rather than looped, since a loop would claim an atomicity the transport does not give. A$batcharm is a follow-up, not filed.containspushed 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.$orderbyon the key. The floor always sends a limit, so the service is asked for a key-ordered read. This is what makesfind'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) andcheck:driver-conformance(a new driver must run every shared case-set).pnpm --filter @objectstack/driver-odata test: 6 files, 425 tests passed;typecheckexit 0;eslint packages/drivers/driver-odataexit 0. The built CJS and ESM entries load (requireandimportofdist/).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