From 6af3207988d129460c550e8947f0327c5c9d6053 Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Sat, 24 Oct 2026 18:22:24 +0000 Subject: [PATCH 1/8] Fix Thrift primary-key metadata for missing objects Signed-off-by: Vu Anh Phung --- lib/thrift-backend/EmptyOperationBackend.ts | 44 +++ lib/thrift-backend/ThriftOperationBackend.ts | 44 ++- lib/thrift-backend/ThriftSessionBackend.ts | 24 +- lib/thrift-backend/metadataErrors.ts | 17 + lib/thrift-backend/primaryKeysSchema.ts | 14 + .../ThriftPrimaryKeysMetadata.test.ts | 325 ++++++++++++++++++ 6 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 lib/thrift-backend/EmptyOperationBackend.ts create mode 100644 lib/thrift-backend/metadataErrors.ts create mode 100644 lib/thrift-backend/primaryKeysSchema.ts create mode 100644 tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts diff --git a/lib/thrift-backend/EmptyOperationBackend.ts b/lib/thrift-backend/EmptyOperationBackend.ts new file mode 100644 index 00000000..daa9c13c --- /dev/null +++ b/lib/thrift-backend/EmptyOperationBackend.ts @@ -0,0 +1,44 @@ +import { v4 } from 'uuid'; +import { TTableSchema } from '../../thrift/TCLIService_types'; +import IOperationBackend from '../contracts/IOperationBackend'; +import { OperationState, OperationStatus } from '../contracts/OperationStatus'; +import { ResultFormat, ResultMetadata } from '../contracts/ResultMetadata'; +import Status from '../dto/Status'; + +export default class EmptyOperationBackend implements IOperationBackend { + public readonly id = v4(); + + constructor(private readonly schema: TTableSchema) {} + + public hasResultSet(): boolean { + return true; + } + + public async fetchChunk(): Promise> { + return []; + } + + public async hasMore(): Promise { + return false; + } + + public async waitUntilReady(): Promise { + return undefined; + } + + public async status(): Promise { + return { state: OperationState.Succeeded, hasResultSet: true }; + } + + public async getResultMetadata(): Promise { + return { schema: this.schema, resultFormat: ResultFormat.ColumnBased, isStagingOperation: false }; + } + + public async cancel(): Promise { + return Status.success(); + } + + public async close(): Promise { + return Status.success(); + } +} diff --git a/lib/thrift-backend/ThriftOperationBackend.ts b/lib/thrift-backend/ThriftOperationBackend.ts index 1111584e..58c95288 100644 --- a/lib/thrift-backend/ThriftOperationBackend.ts +++ b/lib/thrift-backend/ThriftOperationBackend.ts @@ -7,6 +7,8 @@ import { TSparkRowSetType, TCloseOperationResp, TOperationState, + TStatusCode, + TTableSchema, } from '../../thrift/TCLIService_types'; import IOperationBackend, { IOperationBackendWaitOptions } from '../contracts/IOperationBackend'; import IClientContext from '../contracts/IClientContext'; @@ -24,12 +26,15 @@ import ArrowResultConverter from '../result/ArrowResultConverter'; import ResultSlicer from '../result/ResultSlicer'; import { definedOrError } from '../utils'; import HiveDriverError from '../errors/HiveDriverError'; +import isObjectNotFound from './metadataErrors'; +import { synthesizeThriftResultSetMetadata } from './wireSynthesis'; interface ThriftOperationBackendOptions { handle: TOperationHandle; directResults?: TSparkDirectResults; context: IClientContext; functionCatalog?: string | null; + emptyResultOnNotFound?: TTableSchema; } async function delay(ms?: number): Promise { @@ -78,6 +83,8 @@ export default class ThriftOperationBackend implements IOperationBackend { private readonly functionCatalog?: string | null; + private readonly emptyResultOnNotFound?: TTableSchema; + private readonly operationHandle: TOperationHandle; private readonly _data: RowSetProvider; @@ -94,10 +101,18 @@ export default class ThriftOperationBackend implements IOperationBackend { private resultHandler?: ResultSlicer; - constructor({ handle, directResults, context, functionCatalog }: ThriftOperationBackendOptions) { + constructor({ + handle, + directResults, + context, + functionCatalog, + emptyResultOnNotFound, + }: ThriftOperationBackendOptions) { this.operationHandle = handle; this.context = context; this.functionCatalog = functionCatalog; + this.emptyResultOnNotFound = emptyResultOnNotFound; + this.metadata = directResults?.resultSetMetadata; const useOnlyPrefetchedResults = Boolean(directResults?.closeOperation); @@ -105,7 +120,6 @@ export default class ThriftOperationBackend implements IOperationBackend { this.processOperationStatusResponse(directResults.operationStatus); } - this.metadata = directResults?.resultSetMetadata; this._data = new RowSetProvider( this.context, this.operationHandle, @@ -130,7 +144,7 @@ export default class ThriftOperationBackend implements IOperationBackend { } public hasResultSet(): boolean { - return Boolean(this.operationHandle.hasResultSet); + return Boolean(this.emptyResultOnNotFound || this.operationHandle.hasResultSet); } public async fetchChunk({ @@ -377,6 +391,30 @@ export default class ThriftOperationBackend implements IOperationBackend { } private processOperationStatusResponse(response: TGetOperationStatusResp) { + const isError = + response.operationState === TOperationState.ERROR_STATE || + (response.operationState == null && response.status.statusCode === TStatusCode.ERROR_STATUS); + if ( + this.emptyResultOnNotFound && + isError && + response.status.statusCode !== TStatusCode.INVALID_HANDLE_STATUS && + (isObjectNotFound(response) || isObjectNotFound(response.status)) + ) { + this.metadata = synthesizeThriftResultSetMetadata({ + schema: this.emptyResultOnNotFound, + resultFormat: ResultFormat.ColumnBased, + isStagingOperation: false, + }); + this.resultHandler = new ResultSlicer(this.context, { + fetchNext: async () => [], + hasMore: async () => false, + }); + response = { + status: { statusCode: TStatusCode.SUCCESS_STATUS }, + operationState: TOperationState.FINISHED_STATE, + hasResultSet: true, + }; + } Status.assert(response.status); this.state = response.operationState ?? this.state; diff --git a/lib/thrift-backend/ThriftSessionBackend.ts b/lib/thrift-backend/ThriftSessionBackend.ts index 1c63b356..3c2ef266 100644 --- a/lib/thrift-backend/ThriftSessionBackend.ts +++ b/lib/thrift-backend/ThriftSessionBackend.ts @@ -9,6 +9,8 @@ import { TSparkParameter, TProtocolVersion, TExecuteStatementReq, + TStatusCode, + TOperationState, } from '../../thrift/TCLIService_types'; import ISessionBackend from '../contracts/ISessionBackend'; import IOperationBackend from '../contracts/IOperationBackend'; @@ -33,6 +35,9 @@ import { DBSQLParameter, DBSQLParameterValue } from '../DBSQLParameter'; import { LogLevel } from '../contracts/IDBSQLLogger'; import ThriftOperationBackend from './ThriftOperationBackend'; import escapeCatalogName from '../utils/escapeCatalogName'; +import EmptyOperationBackend from './EmptyOperationBackend'; +import isObjectNotFound from './metadataErrors'; +import primaryKeysSchema from './primaryKeysSchema'; interface OperationResponseShape { status: TStatus; @@ -313,7 +318,24 @@ export default class ThriftSessionBackend implements ISessionBackend { runAsync: this.getRunAsyncForMetadataOperations(), ...getDirectResultsOptions(request.maxRows, this.context.getConfig()), }); - return this.createOperationBackend(response); + const notFound = response.status.statusCode === TStatusCode.ERROR_STATUS && isObjectNotFound(response.status); + if (notFound && !response.operationHandle) { + return new EmptyOperationBackend(primaryKeysSchema); + } + if (!notFound) { + Status.assert(response.status); + } + return new ThriftOperationBackend({ + handle: definedOrError(response.operationHandle), + context: this.context, + directResults: notFound + ? { + ...response.directResults, + operationStatus: { status: response.status, operationState: TOperationState.ERROR_STATE }, + } + : response.directResults, + emptyResultOnNotFound: primaryKeysSchema, + }); } public async getCrossReference(request: CrossReferenceRequest): Promise { diff --git a/lib/thrift-backend/metadataErrors.ts b/lib/thrift-backend/metadataErrors.ts new file mode 100644 index 00000000..288294a6 --- /dev/null +++ b/lib/thrift-backend/metadataErrors.ts @@ -0,0 +1,17 @@ +interface MetadataError { + sqlState?: string | null; + errorMessage?: string | null; + displayMessage?: string | null; +} + +export default function isObjectNotFound(error: MetadataError): boolean { + // Match Databricks JDBC's metadata not-found classification. + return ( + error.sqlState === '42704' || + [error.errorMessage, error.displayMessage].some((message) => + ['NO_SUCH_CATALOG_EXCEPTION', 'SCHEMA_NOT_FOUND', 'TABLE_OR_VIEW_NOT_FOUND', 'INVALID_PARAMETER_VALUE'].some( + (errorClass) => message?.includes(errorClass), + ), + ) + ); +} diff --git a/lib/thrift-backend/primaryKeysSchema.ts b/lib/thrift-backend/primaryKeysSchema.ts new file mode 100644 index 00000000..45defb63 --- /dev/null +++ b/lib/thrift-backend/primaryKeysSchema.ts @@ -0,0 +1,14 @@ +import { TTableSchema, TTypeId } from '../../thrift/TCLIService_types'; + +const primaryKeysSchema: TTableSchema = { + columns: ['TABLE_CAT', 'TABLE_SCHEM', 'TABLE_NAME', 'COLUMN_NAME', 'KEY_SEQ', 'PK_NAME'].map((columnName, index) => ({ + columnName, + typeDesc: { + types: [{ primitiveEntry: { type: columnName === 'KEY_SEQ' ? TTypeId.INT_TYPE : TTypeId.STRING_TYPE } }], + }, + position: index + 1, + comment: '', + })), +}; + +export default primaryKeysSchema; diff --git a/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts b/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts new file mode 100644 index 00000000..ac14f366 --- /dev/null +++ b/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts @@ -0,0 +1,325 @@ +import assert from 'node:assert/strict'; +import { expect } from 'chai'; +import Int64 from 'node-int64'; +import sinon from 'sinon'; +import { + TGetOperationStatusResp, + TOperationState, + TOperationType, + TSparkRowSetType, + TStatusCode, + TTypeId, +} from '../../../thrift/TCLIService_types'; +import IOperation from '../../../lib/contracts/IOperation'; +import DBSQLOperation from '../../../lib/DBSQLOperation'; +import { PrimaryKeysRequest } from '../../../lib/contracts/IDBSQLSession'; +import OperationStateError, { OperationStateErrorCode } from '../../../lib/errors/OperationStateError'; +import StatusError from '../../../lib/errors/StatusError'; +import ClientContextStub from '../.stubs/ClientContextStub'; +import { createSessionForTest } from '../.stubs/createSessionForTest'; + +const request = { schemaName: 'missing_schema', tableName: 'table' }; +const expectedColumns = [ + ['TABLE_CAT', TTypeId.STRING_TYPE], + ['TABLE_SCHEM', TTypeId.STRING_TYPE], + ['TABLE_NAME', TTypeId.STRING_TYPE], + ['COLUMN_NAME', TTypeId.STRING_TYPE], + ['KEY_SEQ', TTypeId.INT_TYPE], + ['PK_NAME', TTypeId.STRING_TYPE], +]; + +function createContext(): ClientContextStub { + const context = new ClientContextStub(); + context.driver.getPrimaryKeysResp.operationHandle!.hasResultSet = true; + context.driver.getPrimaryKeysResp.operationHandle!.operationType = TOperationType.GET_FUNCTIONS; + return context; +} + +function createSession(context: ClientContextStub) { + return createSessionForTest({ + handle: { sessionId: { guid: Buffer.alloc(16), secret: Buffer.alloc(16) } }, + context, + }); +} + +function missingSchemaStatus(): TGetOperationStatusResp { + return { + status: { statusCode: TStatusCode.SUCCESS_STATUS }, + operationState: TOperationState.ERROR_STATE, + sqlState: '42704', + errorMessage: '[SCHEMA_NOT_FOUND] The schema cannot be found', + hasResultSet: false, + }; +} + +async function expectEmptyPrimaryKeys(operation: IOperation): Promise { + const schema = await operation.getSchema(); + expect( + schema?.columns.map((column) => [column.columnName, column.typeDesc.types[0].primitiveEntry?.type]), + ).to.deep.equal(expectedColumns); + expect(schema?.columns.map((column) => column.position)).to.deep.equal([1, 2, 3, 4, 5, 6]); + await operation.finished(); + expect(await operation.fetchAll()).to.deep.equal([]); + expect(await operation.hasMoreRows()).to.be.false; + const metadata = await (operation as DBSQLOperation).getMetadata(); + expect(metadata.status.statusCode).to.equal(TStatusCode.SUCCESS_STATUS); + expect(metadata.resultFormat).to.equal(TSparkRowSetType.COLUMN_BASED_SET); + const status = await operation.status(); + expect(status.operationState).to.equal(TOperationState.FINISHED_STATE); + expect(status.status.statusCode).to.equal(TStatusCode.SUCCESS_STATUS); + expect(status.hasResultSet).to.be.true; + expect(status.sqlState).to.be.undefined; + expect(status.errorMessage).to.be.undefined; +} + +describe('Thrift getPrimaryKeys missing objects', () => { + afterEach(() => sinon.restore()); + + const notFoundErrors = [ + { sqlState: '42704', errorMessage: 'Object is missing' }, + { sqlState: 'HY000', errorMessage: '[NO_SUCH_CATALOG_EXCEPTION] Missing catalog' }, + { sqlState: 'HY000', errorMessage: '[SCHEMA_NOT_FOUND] Missing schema' }, + { sqlState: '42P01', errorMessage: '[TABLE_OR_VIEW_NOT_FOUND] Missing table' }, + { sqlState: '22023', errorMessage: '[INVALID_PARAMETER_VALUE] Invalid catalog' }, + ]; + + for (const error of notFoundErrors) { + for (const delivery of ['initial', 'direct', 'poll']) { + it(`returns a typed empty result for ${error.errorMessage} via ${delivery}`, async () => { + const context = createContext(); + const driver = sinon.spy(context.driver); + const failure = { ...missingSchemaStatus(), ...error }; + if (delivery === 'initial') { + context.driver.getPrimaryKeysResp = { + status: { statusCode: TStatusCode.ERROR_STATUS, ...error }, + }; + } else if (delivery === 'direct') { + context.driver.getPrimaryKeysResp.directResults = { + operationStatus: failure, + resultSetMetadata: context.driver.getResultSetMetadataResp, + resultSet: context.driver.fetchResultsResp, + }; + } else { + context.driver.getOperationStatusResp = failure; + } + const operation = await createSession(context).getPrimaryKeys(request); + + await expectEmptyPrimaryKeys(operation); + expect(driver.getResultSetMetadata.called).to.be.false; + expect(driver.fetchResults.called).to.be.false; + expect(driver.getOperationStatus.callCount).to.equal(delivery === 'poll' ? 1 : 0); + await operation.close(); + await operation.close(); + expect(driver.closeOperation.callCount).to.equal(delivery === 'initial' ? 0 : 1); + }); + } + } + + it('closes a real handle returned with an initial error', async () => { + const context = createContext(); + context.driver.getPrimaryKeysResp.status = { + statusCode: TStatusCode.ERROR_STATUS, + sqlState: '42704', + }; + const closeOperation = sinon.spy(context.driver, 'closeOperation'); + const operation = await createSession(context).getPrimaryKeys(request); + + await expectEmptyPrimaryKeys(operation); + await operation.close(); + expect( + closeOperation.calledOnceWithExactly({ + operationHandle: context.driver.getPrimaryKeysResp.operationHandle!, + }), + ).to.be.true; + }); + + it('uses a prefetched close response after recovering direct results', async () => { + const context = createContext(); + context.driver.getPrimaryKeysResp.directResults = { + operationStatus: missingSchemaStatus(), + closeOperation: context.driver.closeOperationResp, + }; + const closeOperation = sinon.spy(context.driver, 'closeOperation'); + const operation = await createSession(context).getPrimaryKeys(request); + + await expectEmptyPrimaryKeys(operation); + expect((await operation.close()).isSuccess).to.be.true; + expect(closeOperation.called).to.be.false; + }); + + it('polls asynchronously and reports recovered success to callbacks', async () => { + const context = createContext(); + const poll = sinon.stub(context.driver, 'getOperationStatus').resolves(missingSchemaStatus()); + poll.onFirstCall().resolves({ + status: { statusCode: TStatusCode.SUCCESS_STATUS }, + operationState: TOperationState.RUNNING_STATE, + }); + const operation = await createSession(context).getPrimaryKeys(request); + expect(poll.called).to.be.false; + const callback = sinon.spy(); + + await operation.finished({ callback }); + expect(callback.args.map(([status]) => status.operationState)).to.deep.equal([ + TOperationState.RUNNING_STATE, + TOperationState.FINISHED_STATE, + ]); + await expectEmptyPrimaryKeys(operation); + }); + + it('recovers a not-found RPC status without an operation state', async () => { + const context = createContext(); + context.driver.getOperationStatusResp = { + status: { statusCode: TStatusCode.ERROR_STATUS, errorMessage: '[SCHEMA_NOT_FOUND] Missing schema' }, + }; + const operation = await createSession(context).getPrimaryKeys(request); + + await expectEmptyPrimaryKeys(operation); + }); + + it('waits for metadata even when the initial handle has no result set', async () => { + const context = createContext(); + context.driver.getPrimaryKeysResp.operationHandle!.hasResultSet = false; + context.driver.getOperationStatusResp = missingSchemaStatus(); + const operation = await createSession(context).getPrimaryKeys(request); + + await expectEmptyPrimaryKeys(operation); + }); + + it('does not recover invalid-handle RPC errors', async () => { + const context = createContext(); + context.driver.getOperationStatusResp = { + ...missingSchemaStatus(), + status: { statusCode: TStatusCode.INVALID_HANDLE_STATUS, errorMessage: 'Invalid handle' }, + }; + const operation = await createSession(context).getPrimaryKeys(request); + + await assert.rejects(operation.finished(), (error) => { + expect(error).to.be.instanceOf(StatusError); + expect(error).to.have.property('message', 'Invalid handle'); + return true; + }); + }); + + it('recognizes a not-found error in displayMessage', async () => { + const context = createContext(); + context.driver.getOperationStatusResp = { + status: { statusCode: TStatusCode.SUCCESS_STATUS }, + operationState: TOperationState.ERROR_STATE, + displayMessage: '[SCHEMA_NOT_FOUND] Missing schema', + }; + const operation = await createSession(context).getPrimaryKeys(request); + + await expectEmptyPrimaryKeys(operation); + }); + + for (const sqlState of ['42501', '28000', 'HYT00', '22023']) { + it(`preserves unrelated failures with SQLSTATE ${sqlState}`, async () => { + const context = createContext(); + const errorStatus = { statusCode: TStatusCode.ERROR_STATUS, sqlState, errorMessage: 'Unrelated failure' }; + context.driver.getPrimaryKeysResp.status = errorStatus; + const session = createSession(context); + await assert.rejects(session.getPrimaryKeys(request), (error) => { + expect(error).to.be.instanceOf(StatusError); + expect(error).to.have.property('sqlState', sqlState); + return true; + }); + context.driver.getPrimaryKeysResp.status = { statusCode: TStatusCode.SUCCESS_STATUS }; + const failure = { ...missingSchemaStatus(), sqlState, errorMessage: 'Unrelated failure' }; + context.driver.getOperationStatusResp = failure; + const operation = await session.getPrimaryKeys(request); + await assert.rejects(operation.fetchAll(), (error) => { + expect(error).to.be.instanceOf(OperationStateError); + expect(error).to.have.property('response', failure); + return true; + }); + }); + } + + for (const [operationState, errorCode] of [ + [TOperationState.CANCELED_STATE, OperationStateErrorCode.Canceled], + [TOperationState.CLOSED_STATE, OperationStateErrorCode.Closed], + [TOperationState.TIMEDOUT_STATE, OperationStateErrorCode.Timeout], + ] as const) { + it(`preserves ${TOperationState[operationState]} even with a not-found message`, async () => { + const context = createContext(); + context.driver.getOperationStatusResp = { ...missingSchemaStatus(), operationState }; + const operation = await createSession(context).getPrimaryKeys(request); + + await assert.rejects(operation.finished(), (error) => { + expect(error).to.be.instanceOf(OperationStateError); + expect(error).to.have.property('errorCode', errorCode); + return true; + }); + }); + } + + for (const method of ['executeStatement', 'getTables']) { + it(`continues throwing missing-object errors for ${method}`, async () => { + const context = createContext(); + context.driver.getOperationStatusResp = missingSchemaStatus(); + const session = createSession(context); + const operation = await (method === 'executeStatement' + ? session.executeStatement('SELECT * FROM missing_schema.table') + : session.getTables(request)); + + await assert.rejects(operation.finished(), OperationStateError); + }); + } + + for (const catalogName of [undefined, null, '', 'catalog']) { + it(`preserves real primary keys with catalog=${JSON.stringify(catalogName)}`, async () => { + const context = createContext(); + context.driver.getResultSetMetadataResp.schema = { + columns: expectedColumns.map(([name, type], index) => ({ + columnName: name === 'KEY_SEQ' ? 'KEQ_SEQ' : String(name), + typeDesc: { types: [{ primitiveEntry: { type: type as TTypeId } }] }, + position: index + 1, + })), + }; + const session = createSession(context); + const operation = await session.getPrimaryKeys({ ...request, catalogName } as PrimaryKeysRequest); + + // The successful server response is authoritative, including its wire metadata. + const metadata = context.driver.getResultSetMetadataResp; + const fetch = sinon.stub(context.driver, 'fetchResults').resolves({ status: metadata.status }); + fetch.onFirstCall().resolves({ + status: metadata.status, + results: { + startRowOffset: new Int64(0), + rows: [], + columns: [ + { stringVal: { values: ['catalog'], nulls: Buffer.alloc(0) } }, + { stringVal: { values: ['schema'], nulls: Buffer.alloc(0) } }, + { stringVal: { values: ['table'], nulls: Buffer.alloc(0) } }, + { stringVal: { values: ['id'], nulls: Buffer.alloc(0) } }, + { i32Val: { values: [1], nulls: Buffer.alloc(0) } }, + { stringVal: { values: ['pk_table'], nulls: Buffer.alloc(0) } }, + ], + }, + }); + + expect(await operation.getSchema()).to.equal(metadata.schema); + expect(await operation.fetchAll()).to.deep.equal([ + { + TABLE_CAT: 'catalog', + TABLE_SCHEM: 'schema', + TABLE_NAME: 'table', + COLUMN_NAME: 'id', + KEQ_SEQ: 1, + PK_NAME: 'pk_table', + }, + ]); + expect(await (operation as DBSQLOperation).getMetadata()).to.equal(metadata); + expect(context.driver.getPrimaryKeysReq?.catalogName).to.equal(catalogName); + }); + } + + it('preserves a successful empty server response', async () => { + const context = createContext(); + context.driver.fetchResultsResp.results = { startRowOffset: new Int64(0), rows: [], columns: [] }; + const operation = await createSession(context).getPrimaryKeys(request); + + expect(await operation.fetchAll()).to.deep.equal([]); + expect(await operation.getSchema()).to.equal(context.driver.getResultSetMetadataResp.schema); + }); +}); From e2764e865480afe634fa018c947b811dde37c1f2 Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Tue, 27 Oct 2026 16:03:37 +0000 Subject: [PATCH 2/8] Isolate fallback primary-key schemas per operation Signed-off-by: Vu Anh Phung --- lib/thrift-backend/ThriftSessionBackend.ts | 6 ++--- lib/thrift-backend/primaryKeysSchema.ts | 26 ++++++++++--------- .../ThriftPrimaryKeysMetadata.test.ts | 25 ++++++++++++++++++ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/lib/thrift-backend/ThriftSessionBackend.ts b/lib/thrift-backend/ThriftSessionBackend.ts index 3c2ef266..2cd0234a 100644 --- a/lib/thrift-backend/ThriftSessionBackend.ts +++ b/lib/thrift-backend/ThriftSessionBackend.ts @@ -37,7 +37,7 @@ import ThriftOperationBackend from './ThriftOperationBackend'; import escapeCatalogName from '../utils/escapeCatalogName'; import EmptyOperationBackend from './EmptyOperationBackend'; import isObjectNotFound from './metadataErrors'; -import primaryKeysSchema from './primaryKeysSchema'; +import createPrimaryKeysSchema from './primaryKeysSchema'; interface OperationResponseShape { status: TStatus; @@ -320,7 +320,7 @@ export default class ThriftSessionBackend implements ISessionBackend { }); const notFound = response.status.statusCode === TStatusCode.ERROR_STATUS && isObjectNotFound(response.status); if (notFound && !response.operationHandle) { - return new EmptyOperationBackend(primaryKeysSchema); + return new EmptyOperationBackend(createPrimaryKeysSchema()); } if (!notFound) { Status.assert(response.status); @@ -334,7 +334,7 @@ export default class ThriftSessionBackend implements ISessionBackend { operationStatus: { status: response.status, operationState: TOperationState.ERROR_STATE }, } : response.directResults, - emptyResultOnNotFound: primaryKeysSchema, + emptyResultOnNotFound: createPrimaryKeysSchema(), }); } diff --git a/lib/thrift-backend/primaryKeysSchema.ts b/lib/thrift-backend/primaryKeysSchema.ts index 45defb63..daf4fa43 100644 --- a/lib/thrift-backend/primaryKeysSchema.ts +++ b/lib/thrift-backend/primaryKeysSchema.ts @@ -1,14 +1,16 @@ import { TTableSchema, TTypeId } from '../../thrift/TCLIService_types'; -const primaryKeysSchema: TTableSchema = { - columns: ['TABLE_CAT', 'TABLE_SCHEM', 'TABLE_NAME', 'COLUMN_NAME', 'KEY_SEQ', 'PK_NAME'].map((columnName, index) => ({ - columnName, - typeDesc: { - types: [{ primitiveEntry: { type: columnName === 'KEY_SEQ' ? TTypeId.INT_TYPE : TTypeId.STRING_TYPE } }], - }, - position: index + 1, - comment: '', - })), -}; - -export default primaryKeysSchema; +export default function createPrimaryKeysSchema(): TTableSchema { + return { + columns: ['TABLE_CAT', 'TABLE_SCHEM', 'TABLE_NAME', 'COLUMN_NAME', 'KEY_SEQ', 'PK_NAME'].map( + (columnName, index) => ({ + columnName, + typeDesc: { + types: [{ primitiveEntry: { type: columnName === 'KEY_SEQ' ? TTypeId.INT_TYPE : TTypeId.STRING_TYPE } }], + }, + position: index + 1, + comment: '', + }), + ), + }; +} diff --git a/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts b/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts index ac14f366..ff5d4cfe 100644 --- a/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts +++ b/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts @@ -115,6 +115,31 @@ describe('Thrift getPrimaryKeys missing objects', () => { } } + for (const delivery of ['initial', 'direct', 'poll'] as const) { + it(`isolates schema mutations from ${delivery} fallback results across sessions`, async () => { + const contexts = { initial: createContext(), direct: createContext(), poll: createContext() }; + contexts.initial.driver.getPrimaryKeysResp = { + status: { statusCode: TStatusCode.ERROR_STATUS, sqlState: '42704' }, + }; + contexts.direct.driver.getPrimaryKeysResp.directResults = { operationStatus: missingSchemaStatus() }; + contexts.poll.driver.getOperationStatusResp = missingSchemaStatus(); + const operation = await createSession(contexts[delivery]).getPrimaryKeys(request); + const schema = await operation.getSchema(); + assert.ok(schema); + + schema.columns[0].columnName = 'renamed'; + schema.columns[0].typeDesc.types[0].primitiveEntry!.type = TTypeId.BOOLEAN_TYPE; + schema.columns.pop(); + + await Promise.all( + Object.values(contexts).map(async (context) => { + const nextOperation = await createSession(context).getPrimaryKeys(request); + await expectEmptyPrimaryKeys(nextOperation); + }), + ); + }); + } + it('closes a real handle returned with an initial error', async () => { const context = createContext(); context.driver.getPrimaryKeysResp.status = { From bf3a1b4f57fc5b4f9758ff3855b7fdd5b708529e Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Tue, 27 Oct 2026 16:57:57 +0000 Subject: [PATCH 3/8] Honor terminal result-set flags and extend metadata coverage Signed-off-by: Vu Anh Phung --- lib/DBSQLOperation.ts | 15 ++-- lib/thrift-backend/ThriftOperationBackend.ts | 2 +- tests/e2e/primary_keys_metadata.test.ts | 64 +++++++++++++ .../ThriftPrimaryKeysMetadata.test.ts | 89 +++++++++++++++++++ 4 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/primary_keys_metadata.test.ts diff --git a/lib/DBSQLOperation.ts b/lib/DBSQLOperation.ts index 1ec1922c..b836b855 100644 --- a/lib/DBSQLOperation.ts +++ b/lib/DBSQLOperation.ts @@ -147,14 +147,13 @@ export default class DBSQLOperation implements IOperation { private async fetchChunkInternal(options?: FetchOptions): Promise> { await this.failIfClosed(); + await this.waitUntilReadyThroughBackend(options); + await this.failIfClosed(); if (!this.backend.hasResultSet()) { return []; } - await this.waitUntilReadyThroughBackend(options); - await this.failIfClosed(); - const defaultMaxRows = this.context.getConfig().fetchChunkDefaultMaxRows; const limit = options?.maxRows ?? defaultMaxRows; const result = await this.backend.fetchChunk({ @@ -247,10 +246,9 @@ export default class DBSQLOperation implements IOperation { return false; } - // Wait for operation to finish before checking for more rows - // This ensures metadata can be fetched successfully - if (this.backend.hasResultSet()) { - await this.waitUntilReadyThroughBackend(); + await this.waitUntilReadyThroughBackend(); + if (!this.backend.hasResultSet()) { + return false; } // If we fetched all the data from server - check if there's anything buffered in result handler @@ -261,13 +259,12 @@ export default class DBSQLOperation implements IOperation { public async getSchema(options?: GetSchemaOptions): Promise { return this.withErrorTelemetry(async () => { await this.failIfClosed(); + await this.waitUntilReadyThroughBackend(options); if (!this.backend.hasResultSet()) { return null; } - await this.waitUntilReadyThroughBackend(options); - this.context.getLogger().log(LogLevel.debug, `Fetching schema for operation with id: ${this.id}`); if (this.backend instanceof ThriftOperationBackend) { const metadata = await this.backend.thriftResultMetadataResponse(); diff --git a/lib/thrift-backend/ThriftOperationBackend.ts b/lib/thrift-backend/ThriftOperationBackend.ts index 58c95288..6d66c567 100644 --- a/lib/thrift-backend/ThriftOperationBackend.ts +++ b/lib/thrift-backend/ThriftOperationBackend.ts @@ -144,7 +144,7 @@ export default class ThriftOperationBackend implements IOperationBackend { } public hasResultSet(): boolean { - return Boolean(this.emptyResultOnNotFound || this.operationHandle.hasResultSet); + return Boolean(this.operationHandle.hasResultSet); } public async fetchChunk({ diff --git a/tests/e2e/primary_keys_metadata.test.ts b/tests/e2e/primary_keys_metadata.test.ts new file mode 100644 index 00000000..f1c616ff --- /dev/null +++ b/tests/e2e/primary_keys_metadata.test.ts @@ -0,0 +1,64 @@ +import { expect } from 'chai'; +import { v4 } from 'uuid'; +import { DBSQLClient } from '../../lib'; +import IDBSQLSession from '../../lib/contracts/IDBSQLSession'; +import { TOperationState, TStatusCode, TTypeId } from '../../thrift/TCLIService_types'; +import config from './utils/config'; + +describe('Thrift primary-key metadata', () => { + const missingSchema = `missing_primary_keys_${v4().replace(/-/g, '')}`; + let client: DBSQLClient; + let session: IDBSQLSession; + + before(async () => { + client = new DBSQLClient(); + await client.connect({ host: config.host, path: config.path, token: config.token }); + session = await client.openSession({ initialCatalog: config.catalog, initialSchema: config.schema }); + }); + + after(async () => { + try { + await session?.close(); + } finally { + await client?.close(); + } + }); + + [1000, null].forEach((maxRows) => { + it(`returns typed empty primary keys for a missing schema with direct results ${ + maxRows === null ? 'off' : 'on' + }`, async () => { + const operation = await session.getPrimaryKeys({ + catalogName: config.catalog, + schemaName: missingSchema, + tableName: 'missing_table', + maxRows, + }); + try { + const schema = await operation.getSchema(); + expect( + schema?.columns.map((column) => [ + column.columnName, + column.typeDesc.types[0].primitiveEntry?.type, + column.position, + ]), + ).to.deep.equal([ + ['TABLE_CAT', TTypeId.STRING_TYPE, 1], + ['TABLE_SCHEM', TTypeId.STRING_TYPE, 2], + ['TABLE_NAME', TTypeId.STRING_TYPE, 3], + ['COLUMN_NAME', TTypeId.STRING_TYPE, 4], + ['KEY_SEQ', TTypeId.INT_TYPE, 5], + ['PK_NAME', TTypeId.STRING_TYPE, 6], + ]); + expect(await operation.fetchAll()).to.deep.equal([]); + expect(await operation.hasMoreRows()).to.be.false; + const status = await operation.status(); + expect(status.status.statusCode).to.equal(TStatusCode.SUCCESS_STATUS); + expect(status.operationState).to.equal(TOperationState.FINISHED_STATE); + expect(status.hasResultSet).to.be.true; + } finally { + await operation.close(); + } + }); + }); +}); diff --git a/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts b/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts index ff5d4cfe..f63336e8 100644 --- a/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts +++ b/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts @@ -81,6 +81,8 @@ describe('Thrift getPrimaryKeys missing objects', () => { { sqlState: 'HY000', errorMessage: '[SCHEMA_NOT_FOUND] Missing schema' }, { sqlState: '42P01', errorMessage: '[TABLE_OR_VIEW_NOT_FOUND] Missing table' }, { sqlState: '22023', errorMessage: '[INVALID_PARAMETER_VALUE] Invalid catalog' }, + { sqlState: '22023', errorMessage: '[INVALID_PARAMETER_VALUE.LENGTH] Identifier is too long' }, + { sqlState: '42501', errorMessage: '[INSUFFICIENT_PERMISSIONS] Wrapped cause: SCHEMA_NOT_FOUND' }, ]; for (const error of notFoundErrors) { @@ -210,6 +212,80 @@ describe('Thrift getPrimaryKeys missing objects', () => { await expectEmptyPrimaryKeys(operation); }); + for (const delivery of ['direct', 'poll']) { + for (const firstCall of ['status', 'getSchema', 'fetchChunk', 'hasMoreRows'] as const) { + it(`honors a terminal no-result flag via ${delivery} starting with ${firstCall}`, async () => { + const context = createContext(); + const driver = sinon.spy(context.driver); + const status = { + status: { statusCode: TStatusCode.SUCCESS_STATUS }, + operationState: TOperationState.FINISHED_STATE, + hasResultSet: false, + }; + if (delivery === 'direct') { + context.driver.getPrimaryKeysResp.directResults = { operationStatus: status }; + } else { + context.driver.getOperationStatusResp = status; + } + const operation = await createSession(context).getPrimaryKeys(request); + + await operation[firstCall](); + + expect(await operation.getSchema()).to.be.null; + expect(await operation.fetchAll()).to.deep.equal([]); + expect(await operation.hasMoreRows()).to.be.false; + expect(driver.getResultSetMetadata.called).to.be.false; + expect(driver.fetchResults.called).to.be.false; + }); + } + } + + it('preserves an unrelated wrapped RPC error without an operation state', async () => { + const context = createContext(); + const status = { + statusCode: TStatusCode.ERROR_STATUS, + sqlState: '22023', + errorMessage: '[INTERNAL_ERROR] Wrapped cause: Invalid parameter value for tableName', + }; + context.driver.getOperationStatusResp = { status }; + const operation = await createSession(context).getPrimaryKeys(request); + + await assert.rejects(operation.fetchAll(), (error) => { + expect(error).to.be.instanceOf(StatusError); + expect(error).to.have.property('sqlState', status.sqlState); + expect(error).to.have.property('message', status.errorMessage); + return true; + }); + }); + + for (const delivery of ['initial', 'direct', 'poll']) { + it(`can cancel after converting a ${delivery} failure to an empty result`, async () => { + const context = createContext(); + const driver = sinon.spy(context.driver); + if (delivery === 'initial') { + context.driver.getPrimaryKeysResp = { + status: { statusCode: TStatusCode.ERROR_STATUS, sqlState: '42704' }, + }; + } else if (delivery === 'direct') { + context.driver.getPrimaryKeysResp.directResults = { operationStatus: missingSchemaStatus() }; + } else { + context.driver.getOperationStatusResp = missingSchemaStatus(); + } + const operation = await createSession(context).getPrimaryKeys(request); + await expectEmptyPrimaryKeys(operation); + + expect((await operation.cancel()).isSuccess).to.be.true; + expect((await operation.cancel()).isSuccess).to.be.true; + expect(driver.cancelOperation.callCount).to.equal(delivery === 'initial' ? 0 : 1); + expect(await operation.hasMoreRows()).to.be.false; + await assert.rejects(operation.fetchAll(), (error) => { + expect(error).to.be.instanceOf(OperationStateError); + expect(error).to.have.property('errorCode', OperationStateErrorCode.Canceled); + return true; + }); + }); + } + it('does not recover invalid-handle RPC errors', async () => { const context = createContext(); context.driver.getOperationStatusResp = { @@ -260,6 +336,19 @@ describe('Thrift getPrimaryKeys missing objects', () => { }); } + it('preserves a terminal error observed before result access', async () => { + const context = createContext(); + const failure = { ...missingSchemaStatus(), sqlState: '42501', errorMessage: 'Permission denied' }; + context.driver.getOperationStatusResp = failure; + const operation = await createSession(context).getPrimaryKeys(request); + + expect((await operation.status()).operationState).to.equal(TOperationState.ERROR_STATE); + for (const method of ['getSchema', 'fetchChunk', 'hasMoreRows'] as const) { + // eslint-disable-next-line no-await-in-loop + await assert.rejects(operation[method](), OperationStateError); + } + }); + for (const [operationState, errorCode] of [ [TOperationState.CANCELED_STATE, OperationStateErrorCode.Canceled], [TOperationState.CLOSED_STATE, OperationStateErrorCode.Closed], From a2ce211669ef44a9fade5b86182bb3acb40eef1c Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Tue, 27 Oct 2026 17:19:42 +0000 Subject: [PATCH 4/8] Revert broad terminal result-set behavior change Signed-off-by: Vu Anh Phung --- lib/DBSQLOperation.ts | 15 ++++++---- lib/thrift-backend/ThriftOperationBackend.ts | 2 +- .../ThriftPrimaryKeysMetadata.test.ts | 28 ------------------- 3 files changed, 10 insertions(+), 35 deletions(-) diff --git a/lib/DBSQLOperation.ts b/lib/DBSQLOperation.ts index b836b855..1ec1922c 100644 --- a/lib/DBSQLOperation.ts +++ b/lib/DBSQLOperation.ts @@ -147,13 +147,14 @@ export default class DBSQLOperation implements IOperation { private async fetchChunkInternal(options?: FetchOptions): Promise> { await this.failIfClosed(); - await this.waitUntilReadyThroughBackend(options); - await this.failIfClosed(); if (!this.backend.hasResultSet()) { return []; } + await this.waitUntilReadyThroughBackend(options); + await this.failIfClosed(); + const defaultMaxRows = this.context.getConfig().fetchChunkDefaultMaxRows; const limit = options?.maxRows ?? defaultMaxRows; const result = await this.backend.fetchChunk({ @@ -246,9 +247,10 @@ export default class DBSQLOperation implements IOperation { return false; } - await this.waitUntilReadyThroughBackend(); - if (!this.backend.hasResultSet()) { - return false; + // Wait for operation to finish before checking for more rows + // This ensures metadata can be fetched successfully + if (this.backend.hasResultSet()) { + await this.waitUntilReadyThroughBackend(); } // If we fetched all the data from server - check if there's anything buffered in result handler @@ -259,12 +261,13 @@ export default class DBSQLOperation implements IOperation { public async getSchema(options?: GetSchemaOptions): Promise { return this.withErrorTelemetry(async () => { await this.failIfClosed(); - await this.waitUntilReadyThroughBackend(options); if (!this.backend.hasResultSet()) { return null; } + await this.waitUntilReadyThroughBackend(options); + this.context.getLogger().log(LogLevel.debug, `Fetching schema for operation with id: ${this.id}`); if (this.backend instanceof ThriftOperationBackend) { const metadata = await this.backend.thriftResultMetadataResponse(); diff --git a/lib/thrift-backend/ThriftOperationBackend.ts b/lib/thrift-backend/ThriftOperationBackend.ts index 6d66c567..58c95288 100644 --- a/lib/thrift-backend/ThriftOperationBackend.ts +++ b/lib/thrift-backend/ThriftOperationBackend.ts @@ -144,7 +144,7 @@ export default class ThriftOperationBackend implements IOperationBackend { } public hasResultSet(): boolean { - return Boolean(this.operationHandle.hasResultSet); + return Boolean(this.emptyResultOnNotFound || this.operationHandle.hasResultSet); } public async fetchChunk({ diff --git a/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts b/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts index f63336e8..9cd30514 100644 --- a/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts +++ b/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts @@ -212,34 +212,6 @@ describe('Thrift getPrimaryKeys missing objects', () => { await expectEmptyPrimaryKeys(operation); }); - for (const delivery of ['direct', 'poll']) { - for (const firstCall of ['status', 'getSchema', 'fetchChunk', 'hasMoreRows'] as const) { - it(`honors a terminal no-result flag via ${delivery} starting with ${firstCall}`, async () => { - const context = createContext(); - const driver = sinon.spy(context.driver); - const status = { - status: { statusCode: TStatusCode.SUCCESS_STATUS }, - operationState: TOperationState.FINISHED_STATE, - hasResultSet: false, - }; - if (delivery === 'direct') { - context.driver.getPrimaryKeysResp.directResults = { operationStatus: status }; - } else { - context.driver.getOperationStatusResp = status; - } - const operation = await createSession(context).getPrimaryKeys(request); - - await operation[firstCall](); - - expect(await operation.getSchema()).to.be.null; - expect(await operation.fetchAll()).to.deep.equal([]); - expect(await operation.hasMoreRows()).to.be.false; - expect(driver.getResultSetMetadata.called).to.be.false; - expect(driver.fetchResults.called).to.be.false; - }); - } - } - it('preserves an unrelated wrapped RPC error without an operation state', async () => { const context = createContext(); const status = { From db5964dad561fb3ca43480aeb12ded86d19ec6c2 Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Wed, 28 Oct 2026 16:15:43 +0000 Subject: [PATCH 5/8] Document missing-object primary-key metadata fix Signed-off-by: Vu Anh Phung --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4b4f26..50850674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - **Breaking:** require Node.js versions containing the CVE-2026-111497 Brotli decoder fix: 22.23.4+, 24.22.1+, or 26.12.1+. Node.js 20, 23, and 25 are no longer supported because those end-of-life lines have no fix. +- Return empty results with the standard schema from Thrift `getPrimaryKeys()` when a catalog, schema, or table is missing, instead of throwing. (#553) ## 2.4.0 From f480b617b4e65a34aae7120ff820f83c9cdbd219 Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Fri, 30 Oct 2026 16:30:10 +0000 Subject: [PATCH 6/8] Preserve Thrift primary-key schema and address review nits Signed-off-by: Vu Anh Phung --- lib/thrift-backend/ThriftOperationBackend.ts | 7 ++ lib/thrift-backend/ThriftSessionBackend.ts | 7 ++ lib/thrift-backend/primaryKeysSchema.ts | 5 +- tests/e2e/primary_keys_metadata.test.ts | 75 ++++++++++--------- .../ThriftPrimaryKeysMetadata.test.ts | 4 +- 5 files changed, 59 insertions(+), 39 deletions(-) diff --git a/lib/thrift-backend/ThriftOperationBackend.ts b/lib/thrift-backend/ThriftOperationBackend.ts index 58c95288..dd5ac327 100644 --- a/lib/thrift-backend/ThriftOperationBackend.ts +++ b/lib/thrift-backend/ThriftOperationBackend.ts @@ -400,6 +400,13 @@ export default class ThriftOperationBackend implements IOperationBackend { response.status.statusCode !== TStatusCode.INVALID_HANDLE_STATUS && (isObjectNotFound(response) || isObjectNotFound(response.status)) ) { + this.context.getLogger().log( + LogLevel.debug, + `Returning empty primary keys for missing object: ${JSON.stringify({ + sqlState: response.sqlState ?? response.status.sqlState, + message: response.errorMessage ?? response.displayMessage ?? response.status.errorMessage, + })}`, + ); this.metadata = synthesizeThriftResultSetMetadata({ schema: this.emptyResultOnNotFound, resultFormat: ResultFormat.ColumnBased, diff --git a/lib/thrift-backend/ThriftSessionBackend.ts b/lib/thrift-backend/ThriftSessionBackend.ts index 2cd0234a..7b562979 100644 --- a/lib/thrift-backend/ThriftSessionBackend.ts +++ b/lib/thrift-backend/ThriftSessionBackend.ts @@ -320,6 +320,13 @@ export default class ThriftSessionBackend implements ISessionBackend { }); const notFound = response.status.statusCode === TStatusCode.ERROR_STATUS && isObjectNotFound(response.status); if (notFound && !response.operationHandle) { + this.context.getLogger().log( + LogLevel.debug, + `Returning empty primary keys for missing object: ${JSON.stringify({ + sqlState: response.status.sqlState, + message: response.status.errorMessage, + })}`, + ); return new EmptyOperationBackend(createPrimaryKeysSchema()); } if (!notFound) { diff --git a/lib/thrift-backend/primaryKeysSchema.ts b/lib/thrift-backend/primaryKeysSchema.ts index daf4fa43..6eb9360a 100644 --- a/lib/thrift-backend/primaryKeysSchema.ts +++ b/lib/thrift-backend/primaryKeysSchema.ts @@ -2,11 +2,12 @@ import { TTableSchema, TTypeId } from '../../thrift/TCLIService_types'; export default function createPrimaryKeysSchema(): TTableSchema { return { - columns: ['TABLE_CAT', 'TABLE_SCHEM', 'TABLE_NAME', 'COLUMN_NAME', 'KEY_SEQ', 'PK_NAME'].map( + // Preserve the Thrift server's KEQ_SEQ spelling. + columns: ['TABLE_CAT', 'TABLE_SCHEM', 'TABLE_NAME', 'COLUMN_NAME', 'KEQ_SEQ', 'PK_NAME'].map( (columnName, index) => ({ columnName, typeDesc: { - types: [{ primitiveEntry: { type: columnName === 'KEY_SEQ' ? TTypeId.INT_TYPE : TTypeId.STRING_TYPE } }], + types: [{ primitiveEntry: { type: columnName === 'KEQ_SEQ' ? TTypeId.INT_TYPE : TTypeId.STRING_TYPE } }], }, position: index + 1, comment: '', diff --git a/tests/e2e/primary_keys_metadata.test.ts b/tests/e2e/primary_keys_metadata.test.ts index f1c616ff..9a82498d 100644 --- a/tests/e2e/primary_keys_metadata.test.ts +++ b/tests/e2e/primary_keys_metadata.test.ts @@ -6,7 +6,7 @@ import { TOperationState, TStatusCode, TTypeId } from '../../thrift/TCLIService_ import config from './utils/config'; describe('Thrift primary-key metadata', () => { - const missingSchema = `missing_primary_keys_${v4().replace(/-/g, '')}`; + const missingName = `missing_primary_keys_${v4().replace(/-/g, '')}`; let client: DBSQLClient; let session: IDBSQLSession; @@ -24,41 +24,46 @@ describe('Thrift primary-key metadata', () => { } }); - [1000, null].forEach((maxRows) => { - it(`returns typed empty primary keys for a missing schema with direct results ${ - maxRows === null ? 'off' : 'on' - }`, async () => { - const operation = await session.getPrimaryKeys({ - catalogName: config.catalog, - schemaName: missingSchema, - tableName: 'missing_table', - maxRows, + [ + { missingObject: 'schema', catalogName: config.catalog, schemaName: missingName }, + { missingObject: 'catalog', catalogName: missingName, schemaName: config.schema }, + ].forEach(({ missingObject, catalogName, schemaName }) => { + [1000, null].forEach((maxRows) => { + it(`returns typed empty primary keys for a missing ${missingObject} with direct results ${ + maxRows === null ? 'off' : 'on' + }`, async () => { + const operation = await session.getPrimaryKeys({ + catalogName, + schemaName, + tableName: 'missing_table', + maxRows, + }); + try { + const schema = await operation.getSchema(); + expect( + schema?.columns.map((column) => [ + column.columnName, + column.typeDesc.types[0].primitiveEntry?.type, + column.position, + ]), + ).to.deep.equal([ + ['TABLE_CAT', TTypeId.STRING_TYPE, 1], + ['TABLE_SCHEM', TTypeId.STRING_TYPE, 2], + ['TABLE_NAME', TTypeId.STRING_TYPE, 3], + ['COLUMN_NAME', TTypeId.STRING_TYPE, 4], + ['KEQ_SEQ', TTypeId.INT_TYPE, 5], + ['PK_NAME', TTypeId.STRING_TYPE, 6], + ]); + expect(await operation.fetchAll()).to.deep.equal([]); + expect(await operation.hasMoreRows()).to.be.false; + const status = await operation.status(); + expect(status.status.statusCode).to.equal(TStatusCode.SUCCESS_STATUS); + expect(status.operationState).to.equal(TOperationState.FINISHED_STATE); + expect(status.hasResultSet).to.be.true; + } finally { + await operation.close(); + } }); - try { - const schema = await operation.getSchema(); - expect( - schema?.columns.map((column) => [ - column.columnName, - column.typeDesc.types[0].primitiveEntry?.type, - column.position, - ]), - ).to.deep.equal([ - ['TABLE_CAT', TTypeId.STRING_TYPE, 1], - ['TABLE_SCHEM', TTypeId.STRING_TYPE, 2], - ['TABLE_NAME', TTypeId.STRING_TYPE, 3], - ['COLUMN_NAME', TTypeId.STRING_TYPE, 4], - ['KEY_SEQ', TTypeId.INT_TYPE, 5], - ['PK_NAME', TTypeId.STRING_TYPE, 6], - ]); - expect(await operation.fetchAll()).to.deep.equal([]); - expect(await operation.hasMoreRows()).to.be.false; - const status = await operation.status(); - expect(status.status.statusCode).to.equal(TStatusCode.SUCCESS_STATUS); - expect(status.operationState).to.equal(TOperationState.FINISHED_STATE); - expect(status.hasResultSet).to.be.true; - } finally { - await operation.close(); - } }); }); }); diff --git a/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts b/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts index 9cd30514..9d239399 100644 --- a/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts +++ b/tests/unit/thrift-backend/ThriftPrimaryKeysMetadata.test.ts @@ -24,7 +24,7 @@ const expectedColumns = [ ['TABLE_SCHEM', TTypeId.STRING_TYPE], ['TABLE_NAME', TTypeId.STRING_TYPE], ['COLUMN_NAME', TTypeId.STRING_TYPE], - ['KEY_SEQ', TTypeId.INT_TYPE], + ['KEQ_SEQ', TTypeId.INT_TYPE], ['PK_NAME', TTypeId.STRING_TYPE], ]; @@ -357,7 +357,7 @@ describe('Thrift getPrimaryKeys missing objects', () => { const context = createContext(); context.driver.getResultSetMetadataResp.schema = { columns: expectedColumns.map(([name, type], index) => ({ - columnName: name === 'KEY_SEQ' ? 'KEQ_SEQ' : String(name), + columnName: String(name), typeDesc: { types: [{ primitiveEntry: { type: type as TTypeId } }] }, position: index + 1, })), From 90f361170f9fa6d61ae05fac542e435145a2a3d4 Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Fri, 30 Oct 2026 16:35:06 +0000 Subject: [PATCH 7/8] Cover missing-table primary-key metadata in E2E tests Signed-off-by: Vu Anh Phung --- tests/e2e/primary_keys_metadata.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/primary_keys_metadata.test.ts b/tests/e2e/primary_keys_metadata.test.ts index 9a82498d..48c0d97b 100644 --- a/tests/e2e/primary_keys_metadata.test.ts +++ b/tests/e2e/primary_keys_metadata.test.ts @@ -27,6 +27,7 @@ describe('Thrift primary-key metadata', () => { [ { missingObject: 'schema', catalogName: config.catalog, schemaName: missingName }, { missingObject: 'catalog', catalogName: missingName, schemaName: config.schema }, + { missingObject: 'table', catalogName: config.catalog, schemaName: config.schema }, ].forEach(({ missingObject, catalogName, schemaName }) => { [1000, null].forEach((maxRows) => { it(`returns typed empty primary keys for a missing ${missingObject} with direct results ${ @@ -35,7 +36,7 @@ describe('Thrift primary-key metadata', () => { const operation = await session.getPrimaryKeys({ catalogName, schemaName, - tableName: 'missing_table', + tableName: missingName, maxRows, }); try { From cb2b3a06117ef70960ce5f9d15208f3e92d53e53 Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Fri, 30 Oct 2026 16:42:31 +0000 Subject: [PATCH 8/8] Allow omitted result-set flag in native metadata status Signed-off-by: Vu Anh Phung --- tests/e2e/primary_keys_metadata.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/primary_keys_metadata.test.ts b/tests/e2e/primary_keys_metadata.test.ts index 48c0d97b..4256bfa5 100644 --- a/tests/e2e/primary_keys_metadata.test.ts +++ b/tests/e2e/primary_keys_metadata.test.ts @@ -60,7 +60,6 @@ describe('Thrift primary-key metadata', () => { const status = await operation.status(); expect(status.status.statusCode).to.equal(TStatusCode.SUCCESS_STATUS); expect(status.operationState).to.equal(TOperationState.FINISHED_STATE); - expect(status.hasResultSet).to.be.true; } finally { await operation.close(); }