Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 44 additions & 0 deletions lib/thrift-backend/EmptyOperationBackend.ts
Original file line number Diff line number Diff line change
@@ -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<Array<object>> {
return [];
}

public async hasMore(): Promise<boolean> {
return false;
}

public async waitUntilReady(): Promise<void> {
return undefined;
}

public async status(): Promise<OperationStatus> {
return { state: OperationState.Succeeded, hasResultSet: true };
}

public async getResultMetadata(): Promise<ResultMetadata> {
return { schema: this.schema, resultFormat: ResultFormat.ColumnBased, isStagingOperation: false };
}

public async cancel(): Promise<Status> {
return Status.success();
}

public async close(): Promise<Status> {
return Status.success();
}
}
51 changes: 48 additions & 3 deletions lib/thrift-backend/ThriftOperationBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<void> {
Expand Down Expand Up @@ -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;
Expand All @@ -94,18 +101,25 @@ export default class ThriftOperationBackend implements IOperationBackend {

private resultHandler?: ResultSlicer<any>;

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);

if (directResults?.operationStatus) {
this.processOperationStatusResponse(directResults.operationStatus);
}

this.metadata = directResults?.resultSetMetadata;
this._data = new RowSetProvider(
this.context,
this.operationHandle,
Expand All @@ -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({
Expand Down Expand Up @@ -377,6 +391,37 @@ 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.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({
Comment thread
vuanhphung marked this conversation as resolved.
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;
Expand Down
31 changes: 30 additions & 1 deletion lib/thrift-backend/ThriftSessionBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
TSparkParameter,
TProtocolVersion,
TExecuteStatementReq,
TStatusCode,
TOperationState,
} from '../../thrift/TCLIService_types';
import ISessionBackend from '../contracts/ISessionBackend';
import IOperationBackend from '../contracts/IOperationBackend';
Expand All @@ -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 createPrimaryKeysSchema from './primaryKeysSchema';

interface OperationResponseShape {
status: TStatus;
Expand Down Expand Up @@ -313,7 +318,31 @@ 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) {
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) {
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: createPrimaryKeysSchema(),
});
}

public async getCrossReference(request: CrossReferenceRequest): Promise<IOperationBackend> {
Expand Down
17 changes: 17 additions & 0 deletions lib/thrift-backend/metadataErrors.ts
Original file line number Diff line number Diff line change
@@ -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) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — isObjectNotFound checks whether the error-class tokens appear anywhere in errorMessage/displayMessage, so it also matches errors that aren't about a missing object. Two cases:

  • Any INVALID_PARAMETER_VALUE* error is treated as not-found. That includes INVALID_PARAMETER_VALUE.LENGTH, which the tests explicitly cover.
  • A permission error that only mentions a not-found class as a wrapped cause ([INSUFFICIENT_PERMISSIONS] Wrapped cause: SCHEMA_NOT_FOUND, also tested) becomes an empty result. The caller then can't tell "no primary keys" apart from "you lack access" or "your argument was rejected".

If this deliberately matches JDBC, add a short comment or link to the JDBC source for the exact rule, so later readers don't narrow it by mistake. Otherwise, consider matching only the leading [ERROR_CLASS] bracket rather than any substring, and leave permission errors alone.

Comment thread
peco-review-bot[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — isObjectNotFound checks whether the error-class token appears anywhere in errorMessage/displayMessage. It doesn't look only at the leading [ERROR_CLASS], and it ignores SQLSTATE. Because of that, errors that have nothing to do with a missing object also turn into a successful empty result:

  • An INSUFFICIENT_PERMISSIONS (SQLSTATE 42501) error whose message happens to mention SCHEMA_NOT_FOUND in a wrapped cause. The new unit tests require this to return empty rows.
  • Any INVALID_PARAMETER_VALUE.* subclass, for example .LENGTH (identifier too long). This is a caller input error, not a missing object.
  • Any message that mentions one of these tokens as free text, such as a table or comment name that contains TABLE_OR_VIEW_NOT_FOUND.

As a result, a caller who lacks privileges or passes an invalid identifier gets "no primary keys" with SUCCESS status and no error, and can't tell that from a real empty result. The only trace is a debug-level log line. Matching JDBC may justify part of this. Still, consider:

  • matching only the leading error class (e.g. /^\[(NO_SUCH_CATALOG_EXCEPTION|SCHEMA_NOT_FOUND|TABLE_OR_VIEW_NOT_FOUND|INVALID_PARAMETER_VALUE)(\]|\.)/), and
  • keeping permission SQLSTATEs (42501/28000) out of this path.

If the wrapped-permission case really is meant to be swallowed, say so in the CHANGELOG entry, because it changes user-visible behavior beyond "missing catalog/schema/table".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fine, to match jdbc

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming that JDBC parity is the goal, and the inline comment does say so. Matching JDBC explains why INVALID_PARAMETER_VALUE and the general not-found classes are in the list. It doesn't cover the cases with real downside: a permission error (42501/28000) gets turned into SUCCESS with empty rows just because a wrapped cause mentions SCHEMA_NOT_FOUND, and the new tests now lock that in as required behavior. If the JDBC driver really does substring-match without checking SQLSTATE, please do at least one of these: link the JDBC source in the comment so future readers can check it, add a permission-SQLSTATE guard, or note in the CHANGELOG that some permission and invalid-parameter errors now come back as empty results. I'm leaving this open so a maintainer can make the final call.

['NO_SUCH_CATALOG_EXCEPTION', 'SCHEMA_NOT_FOUND', 'TABLE_OR_VIEW_NOT_FOUND', 'INVALID_PARAMETER_VALUE'].some(
Comment thread
vuanhphung marked this conversation as resolved.
Comment thread
vuanhphung marked this conversation as resolved.
Comment thread
vuanhphung marked this conversation as resolved.
(errorClass) => message?.includes(errorClass),
Comment thread
vuanhphung marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The INVALID_PARAMETER_VALUE token is matched as a "missing object" and converted into a successful empty primary-key result. Unlike NO_SUCH_CATALOG_EXCEPTION / SCHEMA_NOT_FOUND / TABLE_OR_VIEW_NOT_FOUND, an invalid-parameter error does not imply the object is absent — it signals a malformed request. The test suite encodes this deliberately (e.g. [INVALID_PARAMETER_VALUE.LENGTH] Identifier is too long is expected to yield an empty result), so a caller that passes an over-length or otherwise invalid identifier will see "no primary keys" instead of an error, masking a client bug. The substring match is also order-independent (.includes), so any server message that merely quotes one of these tokens anywhere in its text will be reclassified as not-found.

This appears intentional (the comment claims JDBC parity), so this is a flag-for-confirmation rather than a clear defect: please confirm Databricks JDBC really classifies INVALID_PARAMETER_VALUE as not-found for getPrimaryKeys, and consider tightening to anchored/bracketed error-class matching if the goal is only to catch genuine missing-object classes.

),
)
);
}
17 changes: 17 additions & 0 deletions lib/thrift-backend/primaryKeysSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { TTableSchema, TTypeId } from '../../thrift/TCLIService_types';

export default function createPrimaryKeysSchema(): TTableSchema {
return {
// 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 === 'KEQ_SEQ' ? TTypeId.INT_TYPE : TTypeId.STRING_TYPE } }],
},
position: index + 1,
comment: '',
}),
),
};
}
69 changes: 69 additions & 0 deletions tests/e2e/primary_keys_metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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 missingName = `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();
}
});

[
{ missingObject: 'schema', catalogName: config.catalog, schemaName: missingName },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The PR says it covers a missing catalog, schema, or table, but the e2e matrix only tests a missing schema and a missing catalog. Every case passes tableName: 'missing_table' together with an already-missing parent, so the TABLE_OR_VIEW_NOT_FOUND path (existing catalog and schema, missing table) is never tested end-to-end. That case may come back through a different delivery path, e.g. a successful empty result or an error status. Add a case with catalogName: config.catalog, schemaName: config.schema, tableName: 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 ${
maxRows === null ? 'off' : 'on'
}`, async () => {
const operation = await session.getPrimaryKeys({
catalogName,
schemaName,
tableName: missingName,
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);
} finally {
await operation.close();
}
});
});
});
});
Loading
Loading