feat(SQL Lab): better SQL parsing error messages (#30501)
This commit is contained in:
parent
95325c4673
commit
a098809294
|
|
@ -42,6 +42,7 @@ import {
|
||||||
css,
|
css,
|
||||||
getNumberFormatter,
|
getNumberFormatter,
|
||||||
getExtensionsRegistry,
|
getExtensionsRegistry,
|
||||||
|
ErrorLevel,
|
||||||
ErrorTypeEnum,
|
ErrorTypeEnum,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
|
import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
|
||||||
|
|
@ -540,18 +541,33 @@ const ResultSet = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.state === QueryState.Failed) {
|
if (query.state === QueryState.Failed) {
|
||||||
|
const errors = [];
|
||||||
|
if (query.errorMessage) {
|
||||||
|
errors.push({
|
||||||
|
error_type: ErrorTypeEnum.GENERIC_DB_ENGINE_ERROR,
|
||||||
|
extra: {},
|
||||||
|
level: 'error' as ErrorLevel,
|
||||||
|
message: query.errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
errors.push(...(query.extra?.errors || []), ...(query.errors || []));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResultlessStyles>
|
<ResultlessStyles>
|
||||||
<ErrorMessageWithStackTrace
|
{errors.map((error, index) => (
|
||||||
title={t('Database error')}
|
<ErrorMessageWithStackTrace
|
||||||
error={query?.extra?.errors?.[0] || query?.errors?.[0]}
|
key={index}
|
||||||
subtitle={<MonospaceDiv>{query.errorMessage}</MonospaceDiv>}
|
title={t('Database error')}
|
||||||
copyText={query.errorMessage || undefined}
|
error={error}
|
||||||
link={query.link}
|
subtitle={<MonospaceDiv>{error.message}</MonospaceDiv>}
|
||||||
source="sqllab"
|
copyText={error.message || undefined}
|
||||||
/>
|
link={query.link}
|
||||||
{(query?.extra?.errors?.[0] || query?.errors?.[0])?.error_type ===
|
source="sqllab"
|
||||||
ErrorTypeEnum.FRONTEND_TIMEOUT_ERROR ? (
|
/>
|
||||||
|
))}
|
||||||
|
{errors.some(
|
||||||
|
error => error?.error_type === ErrorTypeEnum.FRONTEND_TIMEOUT_ERROR,
|
||||||
|
) ? (
|
||||||
<Button
|
<Button
|
||||||
className="sql-result-track-job"
|
className="sql-result-track-job"
|
||||||
buttonSize="small"
|
buttonSize="small"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
|
import {
|
||||||
|
ErrorLevel,
|
||||||
|
ErrorSource,
|
||||||
|
ErrorTypeEnum,
|
||||||
|
ThemeProvider,
|
||||||
|
supersetTheme,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
import InvalidSQLErrorMessage from './InvalidSQLErrorMessage';
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
error: {
|
||||||
|
error_type: ErrorTypeEnum.INVALID_SQL_ERROR,
|
||||||
|
message: 'Invalid SQL',
|
||||||
|
level: 'error' as ErrorLevel,
|
||||||
|
extra: {
|
||||||
|
sql: 'SELECT * FFROM table',
|
||||||
|
line: 1,
|
||||||
|
column: 10,
|
||||||
|
engine: 'postgresql',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source: 'test' as ErrorSource,
|
||||||
|
subtitle: 'Test subtitle',
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = (overrides = {}) => (
|
||||||
|
<ThemeProvider theme={supersetTheme}>
|
||||||
|
<InvalidSQLErrorMessage {...defaultProps} {...overrides} />;
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock the ErrorAlert component
|
||||||
|
jest.mock('./ErrorAlert', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
level,
|
||||||
|
source,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
title: React.ReactNode;
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
level: ErrorLevel;
|
||||||
|
source: ErrorSource;
|
||||||
|
body: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<div data-test="error-alert">
|
||||||
|
<div data-test="title">{title}</div>
|
||||||
|
<div data-test="subtitle">{subtitle}</div>
|
||||||
|
<div data-test="level">{level}</div>
|
||||||
|
<div data-test="source">{source}</div>
|
||||||
|
<div data-test="body">{body}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('InvalidSQLErrorMessage', () => {
|
||||||
|
it('renders ErrorAlert with correct props', () => {
|
||||||
|
const { getByTestId } = render(setup());
|
||||||
|
|
||||||
|
expect(getByTestId('error-alert')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('title')).toHaveTextContent('Unable to parse SQL');
|
||||||
|
expect(getByTestId('subtitle')).toHaveTextContent('Test subtitle');
|
||||||
|
expect(getByTestId('level')).toHaveTextContent('error');
|
||||||
|
expect(getByTestId('source')).toHaveTextContent('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the error line and column indicator', () => {
|
||||||
|
const { getByTestId } = render(setup());
|
||||||
|
|
||||||
|
const body = getByTestId('body');
|
||||||
|
expect(body).toContainHTML('<pre>SELECT * FFROM table</pre>');
|
||||||
|
expect(body).toContainHTML('<pre> ^</pre>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing line number', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
setup({
|
||||||
|
error: {
|
||||||
|
...defaultProps.error,
|
||||||
|
extra: { ...defaultProps.error.extra, line: null },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = getByTestId('body');
|
||||||
|
expect(body).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing column number', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
setup({
|
||||||
|
error: {
|
||||||
|
...defaultProps.error,
|
||||||
|
extra: { ...defaultProps.error.extra, column: null },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = getByTestId('body');
|
||||||
|
expect(body).toHaveTextContent('SELECT * FFROM table');
|
||||||
|
expect(body).not.toHaveTextContent('^');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { t } from '@superset-ui/core';
|
||||||
|
import { ErrorMessageComponentProps } from './types';
|
||||||
|
import ErrorAlert from './ErrorAlert';
|
||||||
|
|
||||||
|
interface SupersetParseErrorExtra {
|
||||||
|
sql: string;
|
||||||
|
engine: string | null;
|
||||||
|
line: number | null;
|
||||||
|
column: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Component for showing syntax errors in SQL Lab.
|
||||||
|
*/
|
||||||
|
function InvalidSQLErrorMessage({
|
||||||
|
error,
|
||||||
|
source,
|
||||||
|
subtitle,
|
||||||
|
}: ErrorMessageComponentProps<SupersetParseErrorExtra>) {
|
||||||
|
const { extra, level } = error;
|
||||||
|
|
||||||
|
const { sql, line, column } = extra;
|
||||||
|
const lines = sql.split('\n');
|
||||||
|
const errorLine = line !== null ? lines[line - 1] : null;
|
||||||
|
const body = errorLine && (
|
||||||
|
<>
|
||||||
|
<pre>{errorLine}</pre>
|
||||||
|
{column !== null && <pre>{' '.repeat(column - 1)}^</pre>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
title={t('Unable to parse SQL')}
|
||||||
|
subtitle={subtitle}
|
||||||
|
level={level}
|
||||||
|
source={source}
|
||||||
|
body={body}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InvalidSQLErrorMessage;
|
||||||
|
|
@ -23,6 +23,7 @@ import DatabaseErrorMessage from 'src/components/ErrorMessage/DatabaseErrorMessa
|
||||||
import MarshmallowErrorMessage from 'src/components/ErrorMessage/MarshmallowErrorMessage';
|
import MarshmallowErrorMessage from 'src/components/ErrorMessage/MarshmallowErrorMessage';
|
||||||
import ParameterErrorMessage from 'src/components/ErrorMessage/ParameterErrorMessage';
|
import ParameterErrorMessage from 'src/components/ErrorMessage/ParameterErrorMessage';
|
||||||
import DatasetNotFoundErrorMessage from 'src/components/ErrorMessage/DatasetNotFoundErrorMessage';
|
import DatasetNotFoundErrorMessage from 'src/components/ErrorMessage/DatasetNotFoundErrorMessage';
|
||||||
|
import InvalidSQLErrorMessage from 'src/components/ErrorMessage/InvalidSQLErrorMessage';
|
||||||
import OAuth2RedirectMessage from 'src/components/ErrorMessage/OAuth2RedirectMessage';
|
import OAuth2RedirectMessage from 'src/components/ErrorMessage/OAuth2RedirectMessage';
|
||||||
|
|
||||||
import setupErrorMessagesExtra from './setupErrorMessagesExtra';
|
import setupErrorMessagesExtra from './setupErrorMessagesExtra';
|
||||||
|
|
@ -154,5 +155,9 @@ export default function setupErrorMessages() {
|
||||||
ErrorTypeEnum.OAUTH2_REDIRECT,
|
ErrorTypeEnum.OAUTH2_REDIRECT,
|
||||||
OAuth2RedirectMessage,
|
OAuth2RedirectMessage,
|
||||||
);
|
);
|
||||||
|
errorMessageComponentRegistry.registerValue(
|
||||||
|
ErrorTypeEnum.INVALID_SQL_ERROR,
|
||||||
|
InvalidSQLErrorMessage,
|
||||||
|
);
|
||||||
setupErrorMessagesExtra();
|
setupErrorMessagesExtra();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -323,7 +323,7 @@ class SupersetParseError(SupersetErrorException):
|
||||||
if line:
|
if line:
|
||||||
parts.append(_(" at line %(line)d", line=line))
|
parts.append(_(" at line %(line)d", line=line))
|
||||||
if column:
|
if column:
|
||||||
parts.append(_(":%(column)d", column=column))
|
parts.append(f":{column}")
|
||||||
message = "".join(parts)
|
message = "".join(parts)
|
||||||
|
|
||||||
error = SupersetError(
|
error = SupersetError(
|
||||||
|
|
|
||||||
|
|
@ -238,15 +238,17 @@ def execute_sql_statement( # pylint: disable=too-many-statements, too-many-loca
|
||||||
increased_limit = None if query.limit is None else query.limit + 1
|
increased_limit = None if query.limit is None else query.limit + 1
|
||||||
|
|
||||||
if not database.allow_dml:
|
if not database.allow_dml:
|
||||||
|
errors = []
|
||||||
try:
|
try:
|
||||||
parsed_statement = SQLStatement(sql_statement, engine=db_engine_spec.engine)
|
parsed_statement = SQLStatement(sql_statement, engine=db_engine_spec.engine)
|
||||||
disallowed = parsed_statement.is_mutating()
|
disallowed = parsed_statement.is_mutating()
|
||||||
except SupersetParseError:
|
except SupersetParseError as ex:
|
||||||
# if we fail to parse teh query, disallow by default
|
# if we fail to parse the query, disallow by default
|
||||||
disallowed = True
|
disallowed = True
|
||||||
|
errors.append(ex.error)
|
||||||
|
|
||||||
if disallowed:
|
if disallowed:
|
||||||
raise SupersetErrorException(
|
errors.append(
|
||||||
SupersetError(
|
SupersetError(
|
||||||
message=__(
|
message=__(
|
||||||
"This database does not allow for DDL/DML, and the query "
|
"This database does not allow for DDL/DML, and the query "
|
||||||
|
|
@ -257,6 +259,7 @@ def execute_sql_statement( # pylint: disable=too-many-statements, too-many-loca
|
||||||
level=ErrorLevel.ERROR,
|
level=ErrorLevel.ERROR,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
raise SupersetErrorsException(errors)
|
||||||
|
|
||||||
if apply_ctas:
|
if apply_ctas:
|
||||||
if not query.tmp_table_name:
|
if not query.tmp_table_name:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue