Skip to content

Commit 836d408

Browse files
fix(driver-sql): an Oracle RAW(16) uuid column is filtered and written by the uuid it reads as (#31642)
Fixes #31390 Card items 1, 2 and 4 are in this pull request. Item 3, the type-compat pairing for an Oracle `RAW(16)` column, is carried by #31592 in the spec lane, which waits on this one; nothing under `packages/spec` is touched here. Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:Oracle 联邦表里 `RAW(16)` 的 uuid 列(`SYS_GUID()` 主键及指向它的 lookup),过滤比较值、写入值和按 id 定位的键现在经 `HEXTORAW(?)` 绑定;读侧不变,仍读成带横线的小写 uuid。 - 为什么改:读出来是 uuid 文本,写回去也是文本,Oracle 拒绝文本对 RAW,所以这类 lookup 只能看不能筛、不能写、不能按 id 改删。 - 风险与回滚:列类型取自远端目录(每个对象只读一次 `ALL_TAB_COLUMNS`),读失败就照旧按文本绑定;非 uuid 文本在发库前被拒(过滤 `INVALID_FILTER`,写入 `VALIDATION_ERROR`)。其它方言编译输出逐字节不变,有 pin。回滚即 revert 本 PR。 - 先看:`sql-driver-filter-helpers.ts`(比较值绑定与三处编译拒绝点)、`sql-driver-sqlite-serialisation.ts`(formatInput 写入分支,所有方言共用)、`sql-driver-field-mapping.ts`(目录读取与缓存)。 - 两分钟验证:`pnpm --filter @objectstack/driver-sql exec vitest run src/sql-driver-oracle-dialect.test.ts`,预期 71 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes - **Which columns.** On the Oracle dialect, the first door that touches a federated object reads which of its remote columns are `RAW(16)`, once per object, from `ALL_TAB_COLUMNS`. The owner is the declared remote schema, then the `OWNER.` prefix of the remote name, then `CURRENT_SCHEMA`. The reading is cached per object and dropped when the object is registered again. The column type decides; the value is never the evidence, so a `VARCHAR2` column that holds uuids still binds text. - **Comparands.** A comparison comparand (`=`, `$eq`, `$ne`, `$in`, `$nin`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`) against such a column binds as `HEXTORAW(?)` over the uuid's 32 hex digits, upper case. Pattern operators (`$contains`, `$like` and the rest) compile exactly as before, because their comparand is a text pattern and is never bound to the column. - **Writes and keys.** A written value for such a column (create, update, bulk) and the by-id key of `update`, `delete`, `bulkDelete` and the post-insert read-back bind the same way. The read-back matches the inserted rows by the read form of the id, so an id written upper case or undashed is found again. - **Accepted text.** Uuid text in the dashed read form, or the same 32 digits undashed, in either case. - **Refusals, before anything is sent.** A filter comparand that is not uuid text is refused with the existing `INVALID_FILTER` / 400, through the existing withheld-filter seam, so the field is withheld unless the subtree is author-marked. A written value that is not uuid text is refused with the existing `VALIDATION_ERROR` / 400. - **Failure mode.** A catalog read that throws records nothing: every value binds text as today, and the next door asks again. An empty answer (no `RAW(16)` column) is recorded and binds text. - **Facade.** The priming runs at the top of each door's facet body (find, create, update, delete, bulkCreate, bulkDelete, updateMany, deleteMany, count, aggregate, distinct, findWithWindowFunctions, analyzeQuery). The catalog read lives in `FieldMappingFacet`, the binding in `FilterHelpersFacet`, and `SqlDriver` keeps one-line forwards only. - **Other dialects.** Every new branch is gated on the Oracle client. Their compiled SQL and bindings are byte-identical, pinned per shared helper (see Tests). Error codes reused, none new: `INVALID_FILTER` (filter comparands, through `withheldFilterError`), `VALIDATION_ERROR` (written values). ## Why The driver already reads a `RAW(16)` column as a dashed lowercase uuid (`RAWTOHEX` plus dashes in the fetch type handler), but bound the same text back as a string, which Oracle refuses against `RAW`. A lookup keyed by a `SYS_GUID()` column therefore displayed correctly and could not be filtered, written, updated or deleted by its value. Step 0 on main (`6b83ec2d`, Oracle client compile) showed every uuid comparand and written value bound as dashed text, malformed values sent as they were, and no `HEXTORAW` anywhere. The declared field type cannot tell a `RAW(16)` column from a text one, so the column type has to come from the remote catalog. ## Tests - `sql-driver-oracle-dialect.test.ts`, 30 new tests (71 in the file, 41 on main): - Comparand binding: eq, `$eq` on the key, `$in` with mixed case, undashed 32-hex, `$ne` null-safe, null as `IS NULL`. - Controls: a `VARCHAR2` uuid column binds text (seat pin b), `$contains` is not judged, a uuid in a text column stays text. - `INVALID_FILTER` with nothing sent: equality, 8 hex digits, `$in` with one bad member, `$ne` with a number, 31 hex digits, and through the find door. - Writes: insert compile, a null write, create `VALIDATION_ERROR` with nothing sent, the update, delete and bulkDelete door SQL, and the create read-back for upper-case and undashed ids. - Catalog: read once per object (seat pin d); a failed read records nothing and the next door asks again, and an empty answer binds text and is not re-asked (seat pin a); re-registration forgets the reading. - Every other dialect (seat pin c): pg, mysql2 and mssql compile pins over `coerceFilterValue` and `formatInput` with a poisoned column map, and a sqlite door-level pin capturing the exact statements and bindings of find, create, update, delete, bulkDelete and the read-back. - `sql-driver-oracle-federation-conformance.test.ts`: a live Oracle cell, "RAW(16) uuid keys: filtered, written and looked up by the uuid they read as", covering the SYS_GUID read form, create with an upper-case id, eq and `$in`, the key, the `VARCHAR2` control, a lookup round trip, update and delete by key, `INVALID_FILTER` and `VALIDATION_ERROR`. It runs only with `OS_TEST_ORACLE_URL`. - **Forced test change:** `sql-driver-compile-refusal-seam.test.ts` gains one DOORS row for `assertOracleUuidComparand`. That file enumerates every builder that raises through the withheld-filter seam and is red until each has a row; the row proves the policy, author, unmarked and merged-`$and` provenance cases for the new refusal. ## Acceptance notes - **Live Oracle: NOT MEASURED.** `OS_TEST_ORACLE_URL` is not set in this environment and no CI workflow provides it. The live cell skips through the existing testkit. - **A possible narrowing, required by the card.** A comparand or written value of valid hex but another length, or other non-uuid text, reached Oracle before and is now refused before it is sent. Whether Oracle answered any such value through implicit conversion is NOT MEASURED. Undashed 32-hex uuid text is accepted, to keep that form working. - **create without an id.** `create()` mints a nanoid when no id is given. On an object whose key is a `RAW(16)` column this is now `VALIDATION_ERROR` / 400 instead of an Oracle error. A database-default id (`DEFAULT SYS_GUID()`) is not used by this pull request. - `textMatchPredicate` is not edited; the bind lives in the comparand and write path. ## Verification Head `a535d018`, merged with `origin/main` at `133f75aec0`. The suites and gates below ran on `aed364b6`; the one commit after it (`a535d018`) types the query bags in the new test file, and the oracle dialect file, eslint and the query-options erasure gate were re-run on it. - driver-sql: build exit 0, typecheck exit 0, `eslint --no-inline-config packages/drivers/driver-sql` exit 0. - driver-sql full test: 245 files passed, 13 skipped; 4147 tests passed, 222 skipped; verify-lock VERDICT command-exit 0. - objectql: 570 files, 11571 tests passed; typecheck exit 0; verify-lock VERDICT command-exit 0. - dogfood (closure built under the lock, then `pnpm --filter @objectstack/dogfood test`): 440 files passed, 3 skipped; 3633 tests passed, 41 skipped; VERDICT command-exit 0. - Driver conformance ledger: before (main) 66 covered, 6 DEBT; after 66 covered, 6 DEBT, 0 exempt. - Gates: all 71 families `dispatch-gates` derives for this diff ran and exit 0; `--ran` reconciliation: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN. Two first runs exited 3 on a missing prerequisite (the teardown fixture commit absent from the shallow clone, and type-check-debt not getting the verify lock while dogfood held it) and passed on re-run. `check:query-options-erasure` first went red on 9 new `as any` query bags in the new test; they are typed in `a535d018` and the test surface is back at its 236 ceiling. <details><summary>Ablations (every negative pin), run with scripts/ablation-replace.mjs, each restored to blob == HEAD</summary> | Mutation | Red | |---|---| | B1: the comparand refusal returns early | 10: the five `INVALID_FILTER` cases, the find door, and the four seam provenance rows | | B2: the write refusal binds the value instead of throwing | 1: create `VALIDATION_ERROR` with nothing sent | | C1: the three filter-site Oracle guards and the binding's Oracle guard removed | 4: the pg, mysql2, mssql and sqlite other-dialect pins | | C2: the `formatInput` Oracle guard and the binding's Oracle guard removed | 4: the same four pins | | C3: the binding's Oracle guard alone removed | 1: the sqlite door pin | | C4: the column decided by the value instead of the catalog | 6, among them both controls (`VARCHAR2` column, uuid in a text column) | | C5: a failed catalog read recorded as empty | 1: the failed-read pin | | D1: the once-per-object cache check removed | 2: once per object, and the empty answer not re-asked | | E1: re-registration keeps the stale reading | 1: the re-registration pin | The other-dialect pins were also run against the base source: they pass on main, so they pin unchanged behaviour. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not applicable. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQ9H84XNocJ8waPS1P)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8210894 commit 836d408

17 files changed

Lines changed: 853 additions & 31 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): an Oracle `RAW(16)` uuid column (a `SYS_GUID()` key, or a lookup over one) is now filtered and written by the uuid text it reads back as
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The driver already read a `RAW(16)` column as a lowercase hyphenated uuid, but it bound that same text back as a string, which Oracle refuses against `RAW`. So a lookup keyed by a `SYS_GUID()` column displayed correctly and could not be filtered, written, updated or deleted by its value.
11+
12+
The driver now reads, once per federated object, which of its remote columns are `RAW(16)` (`ALL_TAB_COLUMNS`), and binds a comparand, a written value or a by-id key for those columns as `HEXTORAW(?)` over the uuid's 32 hex digits. The column type decides, never the value: a `VARCHAR2` column holding uuids is bound as text, as before. Uuid text is accepted dashed (the read form) or as the 32 digits undashed, in either case. Anything else is refused before the statement is sent: a filter comparand with `INVALID_FILTER` / 400, a written value with `VALIDATION_ERROR` / 400. A failed catalog read changes nothing: values are bound as before and the next read asks again. Other dialects are unchanged.

‎packages/drivers/driver-sql/src/sql-driver-aggregation.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,26 @@ export interface AggregationHost {
6363
readonly fractionalNumericFields: SqlDriver['fractionalNumericFields'];
6464
getBuilder: SqlDriver['getBuilder'];
6565
readonly isMssql: SqlDriver['isMssql'];
66+
readonly isOracle: SqlDriver['isOracle'];
6667
readonly knex: SqlDriver['knex'];
6768
mapAggregateFunc: SqlDriver['mapAggregateFunc'];
6869
readonly numericFields: SqlDriver['numericFields'];
6970
presentReadColumns: SqlDriver['presentReadColumns'];
7071
primeMssqlGuidColumns: SqlDriver['primeMssqlGuidColumns'];
72+
primeOracleRawUuidColumns: SqlDriver['primeOracleRawUuidColumns'];
7173
readPresentationKind: SqlDriver['readPresentationKind'];
7274
remoteColumn: SqlDriver['remoteColumn'];
7375
}
7476

7577
/** The bodies of the "Aggregation" section of `SqlDriver`: a stateless facet, called by the class's stubs. */
7678
export class AggregationFacet {
7779
/** The body of `SqlDriver#aggregate`; the method's docblock there is its contract. */
78-
static async aggregate(this: Pick<AggregationHost, 'aggregandColumnClassOf' | 'aggregateBackendFault' | 'aliasIdentifierSql' | 'applyFilters' | 'applyTenantScope' | 'buildDateBucketExpr' | 'coercionKey' | 'config' | 'dateGranularityCapabilities' | 'dialectName' | 'foldEmptyAggregateAnswers' | 'getBuilder' | 'isMssql' | 'knex' | 'mapAggregateFunc' | 'presentReadColumns' | 'primeMssqlGuidColumns' | 'readPresentationKind' | 'remoteColumn'>, object: string, query: DriverQuery, options?: DriverOptions): Promise<Record<string, unknown>[]> {
80+
static async aggregate(this: Pick<AggregationHost, 'aggregandColumnClassOf' | 'aggregateBackendFault' | 'aliasIdentifierSql' | 'applyFilters' | 'applyTenantScope' | 'buildDateBucketExpr' | 'coercionKey' | 'config' | 'dateGranularityCapabilities' | 'dialectName' | 'foldEmptyAggregateAnswers' | 'getBuilder' | 'isMssql' | 'isOracle' | 'knex' | 'mapAggregateFunc' | 'presentReadColumns' | 'primeMssqlGuidColumns' | 'primeOracleRawUuidColumns' | 'readPresentationKind' | 'remoteColumn'>, object: string, query: DriverQuery, options?: DriverOptions): Promise<Record<string, unknown>[]> {
7981
// [#31395] SQL Server: which columns are GUIDs, before a text operator compiles.
8082
if (this.isMssql) await this.primeMssqlGuidColumns(object, query.where);
83+
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
84+
// the remote catalog before a comparand or a written value is bound.
85+
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
8186
const builder = this.getBuilder(object, options);
8287
// The column a FIELD names in this statement: its `external.columnMap`
8388
// remote column on a federated object, the field itself otherwise. Every

‎packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,12 @@ export interface BulkOperationsHost {
5959
injectTenantOnInsert: SqlDriver['injectTenantOnInsert'];
6060
readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows'];
6161
readonly isMssql: SqlDriver['isMssql'];
62+
readonly isOracle: SqlDriver['isOracle'];
6263
keepSuppliedUpdatedAt: SqlDriver['keepSuppliedUpdatedAt'];
6364
readonly knex: SqlDriver['knex'];
65+
oracleUuidBinding: SqlDriver['oracleUuidBinding'];
6466
primeMssqlGuidColumns: SqlDriver['primeMssqlGuidColumns'];
67+
primeOracleRawUuidColumns: SqlDriver['primeOracleRawUuidColumns'];
6568
readBackInsertedRows: SqlDriver['readBackInsertedRows'];
6669
resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax'];
6770
remoteColumn: SqlDriver['remoteColumn'];
@@ -79,7 +82,10 @@ export interface BulkOperationsHost {
7982
/** The bodies of the "Bulk & Batch Operations" section of `SqlDriver`: a stateless facet, called by the class's stubs. */
8083
export class BulkOperationsFacet {
8184
/** The body of `SqlDriver#bulkCreate`; the method's docblock there is its contract. */
82-
static async bulkCreate(this: Pick<BulkOperationsHost, 'applyWriteColumnMap' | 'auditMissingTenant' | 'autoNumberCounterKey' | 'collidingAutoNumberReservations' | 'fillAutoNumberFields' | 'formatInput' | 'formatOutput' | 'getBuilder' | 'injectTenantOnInsert' | 'insertReturnsStoredRows' | 'isMssql' | 'readBackInsertedRows' | 'resyncSequenceToDataMax' | 'rotationWriteTarget' | 'stampInsertTimestamps'>, object: string, data: any[], options?: DriverOptions): Promise<Record<string, unknown>[]> {
85+
static async bulkCreate(this: Pick<BulkOperationsHost, 'applyWriteColumnMap' | 'auditMissingTenant' | 'autoNumberCounterKey' | 'collidingAutoNumberReservations' | 'fillAutoNumberFields' | 'formatInput' | 'formatOutput' | 'getBuilder' | 'injectTenantOnInsert' | 'insertReturnsStoredRows' | 'isMssql' | 'isOracle' | 'primeOracleRawUuidColumns' | 'readBackInsertedRows' | 'resyncSequenceToDataMax' | 'rotationWriteTarget' | 'stampInsertTimestamps'>, object: string, data: any[], options?: DriverOptions): Promise<Record<string, unknown>[]> {
86+
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
87+
// the remote catalog before a comparand or a written value is bound.
88+
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
8389
// Same client-side id assignment as create() (id/_id normalization,
8490
// nanoid fallback when neither is supplied) — a row missing an id must
8591
// not be silently inserted with a NULL primary key just because the
@@ -262,21 +268,29 @@ export class BulkOperationsFacet {
262268
}
263269

264270
/** The body of `SqlDriver#bulkDelete`; the method's docblock there is its contract. */
265-
static async bulkDelete(this: Pick<BulkOperationsHost, 'applyTenantScope' | 'auditMissingTenant' | 'getBuilder' | 'remoteColumn' | 'rotationShardsOf'>, object: string, ids: Array<string | number>, options?: DriverOptions): Promise<void> {
271+
static async bulkDelete(this: Pick<BulkOperationsHost, 'applyTenantScope' | 'auditMissingTenant' | 'getBuilder' | 'isOracle' | 'oracleUuidBinding' | 'primeOracleRawUuidColumns' | 'remoteColumn' | 'rotationShardsOf'>, object: string, ids: Array<string | number>, options?: DriverOptions): Promise<void> {
272+
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
273+
// the remote catalog before a comparand or a written value is bound.
274+
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
266275
this.auditMissingTenant(object, 'bulkDelete', options);
267276
for (const target of this.rotationShardsOf(object) ?? [object]) {
268277
// The primary key's column: the `external.columnMap` remote column on a
269278
// federated object, `id` otherwise (a rotation shard is never federated).
270-
const builder = this.getBuilder(target, options).whereIn(this.remoteColumn(object, 'id', 'id'), ids);
279+
// [#31390] On Oracle a RAW(16) uuid key binds each id as `HEXTORAW(?)`.
280+
const keys = ids.map((id) => this.oracleUuidBinding(object, 'id', id, 'comparand'));
281+
const builder = this.getBuilder(target, options).whereIn(this.remoteColumn(object, 'id', 'id'), keys as any[]);
271282
this.applyTenantScope(builder, object, options);
272283
await builder.delete();
273284
}
274285
}
275286

276287
/** The body of `SqlDriver#updateMany`; the method's docblock there is its contract. */
277-
static async updateMany(this: Pick<BulkOperationsHost, 'applyFilters' | 'applyTenantScope' | 'applyWriteColumnMap' | 'auditMissingTenant' | 'formatInput' | 'getBuilder' | 'isMssql' | 'keepSuppliedUpdatedAt' | 'primeMssqlGuidColumns' | 'rotationShardsOf' | 'stampsUpdatedAt' | 'updateWithPresumedTimestamp' | 'updatedAtStamp' | 'updatedAtStampIsPresumed'>, object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise<number> {
288+
static async updateMany(this: Pick<BulkOperationsHost, 'applyFilters' | 'applyTenantScope' | 'applyWriteColumnMap' | 'auditMissingTenant' | 'formatInput' | 'getBuilder' | 'isMssql' | 'isOracle' | 'keepSuppliedUpdatedAt' | 'primeMssqlGuidColumns' | 'primeOracleRawUuidColumns' | 'rotationShardsOf' | 'stampsUpdatedAt' | 'updateWithPresumedTimestamp' | 'updatedAtStamp' | 'updatedAtStampIsPresumed'>, object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise<number> {
278289
// [#31395] SQL Server: which columns are GUIDs, before a text operator compiles.
279290
if (this.isMssql) await this.primeMssqlGuidColumns(object, query.where);
291+
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
292+
// the remote catalog before a comparand or a written value is bound.
293+
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
280294
this.auditMissingTenant(object, 'updateMany', options);
281295

282296
// `data ?? {}` keeps the nullish tolerance this door already had on the
@@ -318,9 +332,12 @@ export class BulkOperationsFacet {
318332
}
319333

320334
/** The body of `SqlDriver#deleteMany`; the method's docblock there is its contract. */
321-
static async deleteMany(this: Pick<BulkOperationsHost, 'applyFilters' | 'applyTenantScope' | 'auditMissingTenant' | 'getBuilder' | 'isMssql' | 'primeMssqlGuidColumns' | 'rotationShardsOf'>, object: string, query: DriverQuery, options?: DriverOptions): Promise<number> {
335+
static async deleteMany(this: Pick<BulkOperationsHost, 'applyFilters' | 'applyTenantScope' | 'auditMissingTenant' | 'getBuilder' | 'isMssql' | 'isOracle' | 'primeMssqlGuidColumns' | 'primeOracleRawUuidColumns' | 'rotationShardsOf'>, object: string, query: DriverQuery, options?: DriverOptions): Promise<number> {
322336
// [#31395] SQL Server: which columns are GUIDs, before a text operator compiles.
323337
if (this.isMssql) await this.primeMssqlGuidColumns(object, query.where);
338+
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
339+
// the remote catalog before a comparand or a written value is bound.
340+
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
324341
this.auditMissingTenant(object, 'deleteMany', options);
325342
let total = 0;
326343
for (const target of this.rotationShardsOf(object) ?? [object]) {
@@ -363,9 +380,12 @@ export class BulkOperationsFacet {
363380
}
364381

365382
/** The body of `SqlDriver#count`; the method's docblock there is its contract. */
366-
static async count(this: Pick<BulkOperationsHost, 'applyFilters' | 'applyTenantScope' | 'backendStatementFault' | 'getBuilder' | 'isMssql' | 'primeMssqlGuidColumns' | 'unresolvableFilterColumnRefusal'>, object: string, query?: DriverQuery, options?: DriverOptions): Promise<number> {
383+
static async count(this: Pick<BulkOperationsHost, 'applyFilters' | 'applyTenantScope' | 'backendStatementFault' | 'getBuilder' | 'isMssql' | 'isOracle' | 'primeMssqlGuidColumns' | 'primeOracleRawUuidColumns' | 'unresolvableFilterColumnRefusal'>, object: string, query?: DriverQuery, options?: DriverOptions): Promise<number> {
367384
// [#31395] SQL Server: which columns are GUIDs, before a text operator compiles.
368385
if (this.isMssql) await this.primeMssqlGuidColumns(object, query?.where);
386+
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
387+
// the remote catalog before a comparand or a written value is bound.
388+
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
369389
const builder = this.getBuilder(object, options);
370390
this.applyTenantScope(builder, object, options);
371391

‎packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,20 @@ const DOORS: readonly Door[] = [
293293
Object.defineProperty(driver, 'isOracle', { get: () => true, configurable: true });
294294
},
295295
},
296+
// ── #31390: a RAW(16) uuid column's comparand must be uuid text ──────────
297+
{
298+
builder: 'assertOracleUuidComparand',
299+
where: () => ({ [POLICY_COL]: SECRET }),
300+
secrets: [POLICY_COL],
301+
klass: 'compares a uuid column with a value that is not a uuid',
302+
// The fixture is SQLite; the refusal reads the dialect and the catalog's
303+
// RAW(16) reading for the object, both set here.
304+
setup: (driver) => {
305+
Object.defineProperty(driver, 'isOracle', { get: () => true, configurable: true });
306+
(driver as unknown as { oracleRawUuidFieldsByObject: Map<string, ReadonlySet<string>> })
307+
.oracleRawUuidFieldsByObject.set('deal', new Set([POLICY_COL]));
308+
},
309+
},
296310
// ── #31327: JSON-array membership on SQL Server, refused rather than guessed ──
297311
{
298312
builder: 'jsonMembershipUnsupportedDialectError',

‎packages/drivers/driver-sql/src/sql-driver-crud.ts‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
isUnresolvableColumnError,
3939
} from './sql-driver-top-level.js';
4040
import type { SqlDriver } from './sql-driver.js';
41+
import { oracleUuidReadForm } from './sql-driver-oracle-dialect.js';
4142

4243
/**
4344
* The `SqlDriver` members the bodies of {@link CrudFacet} read — every one of
@@ -65,12 +66,15 @@ export interface CrudHost {
6566
injectTenantOnInsert: SqlDriver['injectTenantOnInsert'];
6667
readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows'];
6768
readonly isMssql: SqlDriver['isMssql'];
69+
readonly isOracle: SqlDriver['isOracle'];
6870
readonly isSqlite: SqlDriver['isSqlite'];
6971
readonly knex: SqlDriver['knex'];
7072
mapSortField: SqlDriver['mapSortField'];
73+
oracleUuidBinding: SqlDriver['oracleUuidBinding'];
7174
orderKeysFor: SqlDriver['orderKeysFor'];
7275
primeMssqlGuidColumns: SqlDriver['primeMssqlGuidColumns'];
7376
primeMssqlPagingKey: SqlDriver['primeMssqlPagingKey'];
77+
primeOracleRawUuidColumns: SqlDriver['primeOracleRawUuidColumns'];
7478
readBackInsertedRows: SqlDriver['readBackInsertedRows'];
7579
remoteColumn: SqlDriver['remoteColumn'];
7680
resolveTenantField: SqlDriver['resolveTenantField'];
@@ -117,7 +121,10 @@ export class CrudFacet {
117121
* cited that as agreement, which it was not; objectstack#4419.) The
118122
* obligation the contract states is on `find`, and this keeps it there.
119123
*/
120-
static async findRows(this: Pick<CrudHost, 'applyFilters' | 'applyTenantScope' | 'backendStatementFault' | 'formatOutput' | 'getBuilder' | 'isMssql' | 'mapSortField' | 'orderKeysFor' | 'primeMssqlGuidColumns' | 'primeMssqlPagingKey' | 'remoteColumn' | 'unresolvableFilterColumnRefusal'>, object: string, query: DriverQuery, options?: DriverOptions, singleRowLookup = false): Promise<any[]> {
124+
static async findRows(this: Pick<CrudHost, 'applyFilters' | 'applyTenantScope' | 'backendStatementFault' | 'formatOutput' | 'getBuilder' | 'isMssql' | 'isOracle' | 'mapSortField' | 'orderKeysFor' | 'primeMssqlGuidColumns' | 'primeMssqlPagingKey' | 'primeOracleRawUuidColumns' | 'remoteColumn' | 'unresolvableFilterColumnRefusal'>, object: string, query: DriverQuery, options?: DriverOptions, singleRowLookup = false): Promise<any[]> {
125+
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
126+
// the remote catalog before a comparand or a written value is bound.
127+
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
121128
// [#31327] On SQL Server a federated object's paging key is read from the
122129
// remote catalog before the ORDER BY is decided (`primeMssqlPagingKey`).
123130
if (this.isMssql) await this.primeMssqlPagingKey(object);
@@ -274,7 +281,10 @@ export class CrudFacet {
274281
}
275282

276283
/** The body of `SqlDriver#create`; the method's docblock there is its contract. */
277-
static async create(this: Pick<CrudHost, 'applyWriteColumnMap' | 'auditMissingTenant' | 'collidingAutoNumberReservations' | 'decorateHashShadowDuplicate' | 'fillAutoNumberFields' | 'formatInput' | 'formatOutput' | 'getBuilder' | 'injectTenantOnInsert' | 'insertReturnsStoredRows' | 'isMssql' | 'readBackInsertedRows' | 'resyncSequenceToDataMax' | 'rotationWriteTarget' | 'stampInsertTimestamps'>, object: string, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown>> {
284+
static async create(this: Pick<CrudHost, 'applyWriteColumnMap' | 'auditMissingTenant' | 'collidingAutoNumberReservations' | 'decorateHashShadowDuplicate' | 'fillAutoNumberFields' | 'formatInput' | 'formatOutput' | 'getBuilder' | 'injectTenantOnInsert' | 'insertReturnsStoredRows' | 'isMssql' | 'isOracle' | 'primeOracleRawUuidColumns' | 'readBackInsertedRows' | 'resyncSequenceToDataMax' | 'rotationWriteTarget' | 'stampInsertTimestamps'>, object: string, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown>> {
285+
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
286+
// the remote catalog before a comparand or a written value is bound.
287+
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
278288
const { _id, ...rest } = data;
279289
const toInsert = { ...rest };
280290

@@ -418,7 +428,7 @@ export class CrudFacet {
418428
* Refused with {@link insertedRowsNotReadBackError}, never answered with the
419429
* payload: see that function for why.
420430
*/
421-
static async readBackInsertedRows(this: Pick<CrudHost, 'applyTenantScope' | 'formatOutput' | 'getBuilder' | 'remoteColumn' | 'resolveTenantField'>, object: string, writeTable: string, written: Record<string, any>[], options?: DriverOptions): Promise<Record<string, unknown>[]> {
431+
static async readBackInsertedRows(this: Pick<CrudHost, 'applyTenantScope' | 'formatOutput' | 'getBuilder' | 'oracleUuidBinding' | 'remoteColumn' | 'resolveTenantField'>, object: string, writeTable: string, written: Record<string, any>[], options?: DriverOptions): Promise<Record<string, unknown>[]> {
422432
if (written.length === 0) return [];
423433
const idColumn = this.remoteColumn(object, 'id', 'id');
424434
const tenantField = this.resolveTenantField(object);
@@ -440,15 +450,17 @@ export class CrudFacet {
440450
: { ...options };
441451
const builder = this.getBuilder(writeTable, options);
442452
this.applyTenantScope(builder, object, scopeOptions);
443-
const stored: Record<string, any>[] = await builder.whereIn(
444-
idColumn,
445-
written.map((row) => row.id),
446-
);
453+
// [#31390] On Oracle a RAW(16) uuid key binds each id as `HEXTORAW(?)`, and
454+
// reads back in the lowercase hyphenated form whatever form it was written in.
455+
const keys = written.map((row) => this.oracleUuidBinding(object, 'id', row.id, 'comparand'));
456+
const writtenKey = (row: Record<string, any>, i: number): string =>
457+
keys[i] === row.id ? String(row.id) : oracleUuidReadForm(String(row.id));
458+
const stored: Record<string, any>[] = await builder.whereIn(idColumn, keys as any[]);
447459
const byId = new Map<string, Record<string, any>>();
448460
for (const row of stored) byId.set(String(row[idColumn]), row);
449-
const missing = written.filter((row) => !byId.has(String(row.id))).map((row) => row.id);
461+
const missing = written.filter((row, i) => !byId.has(writtenKey(row, i))).map((row) => row.id);
450462
if (missing.length > 0) throw insertedRowsNotReadBackError(object, missing, written.length);
451-
return written.map((row) => this.formatOutput(object, byId.get(String(row.id))));
463+
return written.map((row, i) => this.formatOutput(object, byId.get(writtenKey(row, i))));
452464
}
453465

454466
/** The body of `SqlDriver#ensureSequencesTable`; the method's docblock there is its contract. */

0 commit comments

Comments
 (0)