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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/31390-oracle-raw16-uuid-hextoraw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/driver-sql': patch
---

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

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

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.

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.
7 changes: 6 additions & 1 deletion packages/drivers/driver-sql/src/sql-driver-aggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,26 @@ export interface AggregationHost {
readonly fractionalNumericFields: SqlDriver['fractionalNumericFields'];
getBuilder: SqlDriver['getBuilder'];
readonly isMssql: SqlDriver['isMssql'];
readonly isOracle: SqlDriver['isOracle'];
readonly knex: SqlDriver['knex'];
mapAggregateFunc: SqlDriver['mapAggregateFunc'];
readonly numericFields: SqlDriver['numericFields'];
presentReadColumns: SqlDriver['presentReadColumns'];
primeMssqlGuidColumns: SqlDriver['primeMssqlGuidColumns'];
primeOracleRawUuidColumns: SqlDriver['primeOracleRawUuidColumns'];
readPresentationKind: SqlDriver['readPresentationKind'];
remoteColumn: SqlDriver['remoteColumn'];
}

/** The bodies of the "Aggregation" section of `SqlDriver`: a stateless facet, called by the class's stubs. */
export class AggregationFacet {
/** The body of `SqlDriver#aggregate`; the method's docblock there is its contract. */
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>[]> {
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>[]> {
// [#31395] SQL Server: which columns are GUIDs, before a text operator compiles.
if (this.isMssql) await this.primeMssqlGuidColumns(object, query.where);
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
// the remote catalog before a comparand or a written value is bound.
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
const builder = this.getBuilder(object, options);
// The column a FIELD names in this statement: its `external.columnMap`
// remote column on a federated object, the field itself otherwise. Every
Expand Down
32 changes: 26 additions & 6 deletions packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,12 @@ export interface BulkOperationsHost {
injectTenantOnInsert: SqlDriver['injectTenantOnInsert'];
readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows'];
readonly isMssql: SqlDriver['isMssql'];
readonly isOracle: SqlDriver['isOracle'];
keepSuppliedUpdatedAt: SqlDriver['keepSuppliedUpdatedAt'];
readonly knex: SqlDriver['knex'];
oracleUuidBinding: SqlDriver['oracleUuidBinding'];
primeMssqlGuidColumns: SqlDriver['primeMssqlGuidColumns'];
primeOracleRawUuidColumns: SqlDriver['primeOracleRawUuidColumns'];
readBackInsertedRows: SqlDriver['readBackInsertedRows'];
resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax'];
remoteColumn: SqlDriver['remoteColumn'];
Expand All @@ -79,7 +82,10 @@ export interface BulkOperationsHost {
/** The bodies of the "Bulk & Batch Operations" section of `SqlDriver`: a stateless facet, called by the class's stubs. */
export class BulkOperationsFacet {
/** The body of `SqlDriver#bulkCreate`; the method's docblock there is its contract. */
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>[]> {
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>[]> {
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
// the remote catalog before a comparand or a written value is bound.
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
// Same client-side id assignment as create() (id/_id normalization,
// nanoid fallback when neither is supplied) — a row missing an id must
// not be silently inserted with a NULL primary key just because the
Expand Down Expand Up @@ -262,21 +268,29 @@ export class BulkOperationsFacet {
}

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

/** The body of `SqlDriver#updateMany`; the method's docblock there is its contract. */
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> {
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> {
// [#31395] SQL Server: which columns are GUIDs, before a text operator compiles.
if (this.isMssql) await this.primeMssqlGuidColumns(object, query.where);
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
// the remote catalog before a comparand or a written value is bound.
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
this.auditMissingTenant(object, 'updateMany', options);

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

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,20 @@ const DOORS: readonly Door[] = [
Object.defineProperty(driver, 'isOracle', { get: () => true, configurable: true });
},
},
// ── #31390: a RAW(16) uuid column's comparand must be uuid text ──────────
{
builder: 'assertOracleUuidComparand',
where: () => ({ [POLICY_COL]: SECRET }),
secrets: [POLICY_COL],
klass: 'compares a uuid column with a value that is not a uuid',
// The fixture is SQLite; the refusal reads the dialect and the catalog's
// RAW(16) reading for the object, both set here.
setup: (driver) => {
Object.defineProperty(driver, 'isOracle', { get: () => true, configurable: true });
(driver as unknown as { oracleRawUuidFieldsByObject: Map<string, ReadonlySet<string>> })
.oracleRawUuidFieldsByObject.set('deal', new Set([POLICY_COL]));
},
},
// ── #31327: JSON-array membership on SQL Server, refused rather than guessed ──
{
builder: 'jsonMembershipUnsupportedDialectError',
Expand Down
30 changes: 21 additions & 9 deletions packages/drivers/driver-sql/src/sql-driver-crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
isUnresolvableColumnError,
} from './sql-driver-top-level.js';
import type { SqlDriver } from './sql-driver.js';
import { oracleUuidReadForm } from './sql-driver-oracle-dialect.js';

/**
* The `SqlDriver` members the bodies of {@link CrudFacet} read — every one of
Expand Down Expand Up @@ -65,12 +66,15 @@ export interface CrudHost {
injectTenantOnInsert: SqlDriver['injectTenantOnInsert'];
readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows'];
readonly isMssql: SqlDriver['isMssql'];
readonly isOracle: SqlDriver['isOracle'];
readonly isSqlite: SqlDriver['isSqlite'];
readonly knex: SqlDriver['knex'];
mapSortField: SqlDriver['mapSortField'];
oracleUuidBinding: SqlDriver['oracleUuidBinding'];
orderKeysFor: SqlDriver['orderKeysFor'];
primeMssqlGuidColumns: SqlDriver['primeMssqlGuidColumns'];
primeMssqlPagingKey: SqlDriver['primeMssqlPagingKey'];
primeOracleRawUuidColumns: SqlDriver['primeOracleRawUuidColumns'];
readBackInsertedRows: SqlDriver['readBackInsertedRows'];
remoteColumn: SqlDriver['remoteColumn'];
resolveTenantField: SqlDriver['resolveTenantField'];
Expand Down Expand Up @@ -117,7 +121,10 @@ export class CrudFacet {
* cited that as agreement, which it was not; objectstack#4419.) The
* obligation the contract states is on `find`, and this keeps it there.
*/
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[]> {
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[]> {
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
// the remote catalog before a comparand or a written value is bound.
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
// [#31327] On SQL Server a federated object's paging key is read from the
// remote catalog before the ORDER BY is decided (`primeMssqlPagingKey`).
if (this.isMssql) await this.primeMssqlPagingKey(object);
Expand Down Expand Up @@ -274,7 +281,10 @@ export class CrudFacet {
}

/** The body of `SqlDriver#create`; the method's docblock there is its contract. */
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>> {
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>> {
// [#31390] On Oracle a federated object's RAW(16) uuid columns are read from
// the remote catalog before a comparand or a written value is bound.
if (this.isOracle) await this.primeOracleRawUuidColumns(object);
const { _id, ...rest } = data;
const toInsert = { ...rest };

Expand Down Expand Up @@ -418,7 +428,7 @@ export class CrudFacet {
* Refused with {@link insertedRowsNotReadBackError}, never answered with the
* payload: see that function for why.
*/
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>[]> {
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>[]> {
if (written.length === 0) return [];
const idColumn = this.remoteColumn(object, 'id', 'id');
const tenantField = this.resolveTenantField(object);
Expand All @@ -440,15 +450,17 @@ export class CrudFacet {
: { ...options };
const builder = this.getBuilder(writeTable, options);
this.applyTenantScope(builder, object, scopeOptions);
const stored: Record<string, any>[] = await builder.whereIn(
idColumn,
written.map((row) => row.id),
);
// [#31390] On Oracle a RAW(16) uuid key binds each id as `HEXTORAW(?)`, and
// reads back in the lowercase hyphenated form whatever form it was written in.
const keys = written.map((row) => this.oracleUuidBinding(object, 'id', row.id, 'comparand'));
const writtenKey = (row: Record<string, any>, i: number): string =>
keys[i] === row.id ? String(row.id) : oracleUuidReadForm(String(row.id));
const stored: Record<string, any>[] = await builder.whereIn(idColumn, keys as any[]);
const byId = new Map<string, Record<string, any>>();
for (const row of stored) byId.set(String(row[idColumn]), row);
const missing = written.filter((row) => !byId.has(String(row.id))).map((row) => row.id);
const missing = written.filter((row, i) => !byId.has(writtenKey(row, i))).map((row) => row.id);
if (missing.length > 0) throw insertedRowsNotReadBackError(object, missing, written.length);
return written.map((row) => this.formatOutput(object, byId.get(String(row.id))));
return written.map((row, i) => this.formatOutput(object, byId.get(writtenKey(row, i))));
}

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