Compare commits

...

21 Commits

Author SHA1 Message Date
Enzo Martellucci
edc8e4b3ab Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-04-27 10:33:23 +02:00
Raffael Zampieri
ea8a8f8ac7 feat(i18n): improve pt_BR translations (#38826) 2026-04-27 01:33:04 -07:00
Enzo Martellucci
f6ac345ef3 fix(cypress): wait for final validation to settle before asserting button state 2026-03-18 15:24:12 +01:00
Enzo Martellucci
d036ef4455 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-03-18 14:57:15 +01:00
Enzo Martellucci
6c69cc23ea lint 2026-03-18 14:56:58 +01:00
Enzo Martellucci
15b28631bf chore: remove duplicated handleClearValidationErrors function 2026-03-18 14:44:13 +01:00
Enzo Martellucci
e7c9cf0d04 refactor(database): simplify SSH tunnel error accumulation in useDatabaseValidation 2026-03-18 11:57:35 +01:00
Enzo Martellucci
2f980320b6 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-03-18 11:55:33 +01:00
Enzo Martellucci
d1ec3ebb40 Merge branch master into enxdev/feat/enhance-database-modal-validation 2026-03-12 23:17:01 +01:00
Enzo Martellucci
13ed9b5bad fix CI test 2026-02-16 16:32:56 +01:00
Enzo Martellucci
0bfaf3c50e perf(database): skip redundant validation API calls on blur 2026-02-16 11:10:34 +01:00
Enzo Martellucci
055fa360bb Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-02-14 21:46:20 +01:00
Enzo Martellucci
7d53e4d708 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-02-10 16:03:25 +01:00
Enzo Martellucci
c0be0485b3 Merge branch master into enxdev/feat/enhance-database-modal-validation 2026-02-04 09:41:19 +01:00
Enzo Martellucci
84c228e28b Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-01-28 11:27:56 +01:00
Enzo Martellucci
899ecf8214 fix: update RTL tests to match new behavior 2026-01-20 11:16:06 +01:00
Enzo Martellucci
4e156dc41e Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-01-20 09:44:00 +01:00
Enzo Martellucci
2df224370e Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-01-13 16:41:44 +01:00
Enzo Martellucci
a44b8a6cf0 feat(database): add SSH tunnel validation to database parameters endpoint
- Remove early return in frontend validation hook when SSH is enabled,
  allowing backend validation to run for all database parameters
- Add SSH tunnel field validation in ValidateDatabaseParametersCommand
  to validate server_address, server_port, username, and credentials
- Add DatabaseSSHTunnelValidation schema for partial SSH tunnel data
  validation without strict authentication requirements
- Add ssh_tunnel field to DatabaseValidateParametersSchema
- Parse SSH tunnel errors in frontend and display under ssh_tunnel key
- Collect database_name duplicate errors alongside parameter errors
2026-01-02 17:53:01 +01:00
Enzo Martellucci
ad92ec683b wip: add validation loading state to SSH tunnel form fields 2025-12-31 17:35:50 +01:00
Enzo Martellucci
6aef573304 feat(database): add validation loading state and duplicate name check
- Add isValidating prop to TableCatalog, ValidatedInputField, and
  CommonParameters to show loading spinner during validation
- Fix LabeledErrorBoundInput hasFeedback to display spinner while
  validating, not just on errors
- Add duplicate database name validation to validate_parameters
  endpoint for real-time feedback before form submission
2025-12-31 17:23:13 +01:00
12 changed files with 2167 additions and 2141 deletions

View File

@@ -68,21 +68,19 @@ describe('Add database', () => {
cy.get('input[name="username"]').type('testusername', { force: true });
cy.get('input[name="database"]').type('testdb', { force: true });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.get('body').click(0, 0);
cy.wait('@validateParams', { timeout: 30000 });
cy.getBySel('btn-submit-connection').should('not.be.disabled');
// Wait for all intermediate validation calls to settle, then check the button
cy.getBySel('btn-submit-connection').should('not.be.disabled', {
timeout: 60000,
});
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
"The hostname provided can't be resolved",
).should('exist');
});
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
"The hostname provided can't be resolved",
).should('exist');
});
});
@@ -90,29 +88,22 @@ describe('Add database', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('localhost', { force: true });
cy.get('body').click(0, 0);
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="port"]').type('5430', { force: true });
cy.get('input[name="database"]').type('testdb', { force: true });
cy.get('input[name="username"]').type('testusername', { force: true });
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.wait('@validateParams');
cy.get('body').click(0, 0);
cy.getBySel('btn-submit-connection').should('not.be.disabled');
// Wait for all intermediate validation calls to settle, then check the button
cy.getBySel('btn-submit-connection').should('not.be.disabled', {
timeout: 60000,
});
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.get('body').click(0, 0);
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
'The port is closed',
).should('exist');
});
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains('.ant-form-item-explain-error', 'The port is closed').should(
'exist',
);
});
});
});

View File

@@ -79,7 +79,7 @@ export const LabeledErrorBoundInput = ({
isValidating ? 'validating' : hasError ? 'error' : 'success'
}
help={errorMessage || helpText}
hasFeedback={!!hasError}
hasFeedback={isValidating || !!hasError}
>
{visibilityToggle || props.name === 'password' ? (
<StyledInputPassword

View File

@@ -243,6 +243,7 @@ export const accessTokenField = ({
validationErrors,
db,
isEditMode,
isValidating,
default_value,
description,
}: FieldPropTypes) => (
@@ -250,6 +251,7 @@ export const accessTokenField = ({
id="access_token"
name="access_token"
required={required}
isValidating={isValidating}
visibilityToggle={!isEditMode}
value={db?.parameters?.access_token}
validationMethods={{ onBlur: getValidation }}

View File

@@ -33,6 +33,7 @@ export const TableCatalog = ({
getValidation,
validationErrors,
db,
isValidating,
}: FieldPropTypes) => {
const tableCatalog = db?.catalog || [];
const catalogError = validationErrors || {};
@@ -51,6 +52,7 @@ export const TableCatalog = ({
<ValidatedInput
className="catalog-name-input"
required={required}
isValidating={isValidating}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.name}
placeholder={t('Enter a name for this sheet')}
@@ -84,6 +86,7 @@ export const TableCatalog = ({
<ValidatedInput
className="catalog-name-url"
required={required}
isValidating={isValidating}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.url}
placeholder={t('Paste the shareable Google Sheet URL here')}

View File

@@ -49,11 +49,13 @@ export const validatedInputField = ({
validationErrors,
db,
field,
isValidating,
}: FieldPropTypes) => (
<ValidatedInput
id={field}
name={field}
required={required}
isValidating={isValidating}
value={db?.parameters?.[field as keyof DatabaseParameters]}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.[field]}

View File

@@ -18,18 +18,20 @@
*/
import { useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { JsonObject } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import {
Form,
FormLabel,
Col,
Row,
LabeledErrorBoundInput,
Icons,
Tooltip,
} from '@superset-ui/core/components';
import { Input } from '@superset-ui/core/components/Input';
import { Radio } from '@superset-ui/core/components/Radio';
import { Icons } from '@superset-ui/core/components/Icons';
import { DatabaseObject, FieldPropTypes } from '../types';
import { DatabaseObject, CustomEventHandlerType } from '../types';
import { AuthType } from '.';
const StyledDiv = styled.div`
@@ -48,50 +50,60 @@ const StyledFormItem = styled(Form.Item)`
margin-bottom: 0 !important;
`;
const StyledInputPassword = styled(Input.Password)`
margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
`;
interface SSHTunnelFormProps {
db: DatabaseObject | null;
onSSHTunnelParametersChange: CustomEventHandlerType;
setSSHTunnelLoginMethod: (method: AuthType) => void;
isValidating?: boolean;
validationErrors?: JsonObject | null;
getValidation: () => void;
}
const SSHTunnelForm = ({
db,
onSSHTunnelParametersChange,
setSSHTunnelLoginMethod,
}: {
db: DatabaseObject | null;
onSSHTunnelParametersChange: FieldPropTypes['changeMethods']['onSSHTunnelParametersChange'];
setSSHTunnelLoginMethod: (method: AuthType) => void;
}) => {
isValidating = false,
validationErrors,
getValidation,
}: SSHTunnelFormProps) => {
const [usePassword, setUsePassword] = useState<AuthType>(AuthType.Password);
const sshErrors = validationErrors?.ssh_tunnel || {};
return (
<Form>
<StyledRow gutter={16}>
<Col xs={24} md={12}>
<StyledDiv>
<FormLabel htmlFor="server_address" required>
{t('SSH Host')}
</FormLabel>
<Input
<LabeledErrorBoundInput
id="server_address"
name="server_address"
type="text"
label={t('SSH Host')}
required
placeholder={t('e.g. 127.0.0.1')}
value={db?.ssh_tunnel?.server_address || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.server_address}
isValidating={isValidating}
data-test="ssh-tunnel-server_address-input"
/>
</StyledDiv>
</Col>
<Col xs={24} md={12}>
<StyledDiv>
<FormLabel htmlFor="server_port" required>
{t('SSH Port')}
</FormLabel>
<Input
<LabeledErrorBoundInput
id="server_port"
name="server_port"
label={t('SSH Port')}
required
placeholder={t('22')}
type="number"
value={db?.ssh_tunnel?.server_port}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.server_port}
isValidating={isValidating}
data-test="ssh-tunnel-server_port-input"
/>
</StyledDiv>
@@ -100,15 +112,17 @@ const SSHTunnelForm = ({
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<FormLabel htmlFor="username" required>
{t('Username')}
</FormLabel>
<Input
<LabeledErrorBoundInput
id="username"
name="username"
type="text"
label={t('Username')}
required
placeholder={t('e.g. Analytics')}
value={db?.ssh_tunnel?.username || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.username}
isValidating={isValidating}
data-test="ssh-tunnel-username-input"
/>
</StyledDiv>
@@ -148,16 +162,20 @@ const SSHTunnelForm = ({
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<FormLabel htmlFor="password" required>
{t('SSH Password')}
</FormLabel>
<StyledInputPassword
<LabeledErrorBoundInput
id="password"
name="password"
label={t('SSH Password')}
required
visibilityToggle
placeholder={t('e.g. ********')}
value={db?.ssh_tunnel?.password || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.password}
isValidating={isValidating}
data-test="ssh-tunnel-password-input"
iconRender={visible =>
iconRender={(visible: boolean) =>
visible ? (
<Tooltip title={t('Hide password.')}>
<Icons.EyeInvisibleOutlined />
@@ -182,30 +200,47 @@ const SSHTunnelForm = ({
<FormLabel htmlFor="private_key" required>
{t('Private Key')}
</FormLabel>
<Input.TextArea
name="private_key"
placeholder={t('Paste Private Key here')}
value={db?.ssh_tunnel?.private_key || ''}
onChange={onSSHTunnelParametersChange}
data-test="ssh-tunnel-private_key-input"
rows={4}
/>
<StyledFormItem
validateStatus={
isValidating
? 'validating'
: sshErrors?.private_key
? 'error'
: 'success'
}
help={sshErrors?.private_key}
hasFeedback={isValidating || !!sshErrors?.private_key}
>
<Input.TextArea
name="private_key"
placeholder={t('Paste Private Key here')}
value={db?.ssh_tunnel?.private_key || ''}
onChange={onSSHTunnelParametersChange}
onBlur={getValidation}
data-test="ssh-tunnel-private_key-input"
rows={4}
/>
</StyledFormItem>
</StyledDiv>
</Col>
</StyledRow>
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<FormLabel htmlFor="private_key_password" required>
{t('Private Key Password')}
</FormLabel>
<StyledInputPassword
<LabeledErrorBoundInput
id="private_key_password"
name="private_key_password"
label={t('Private Key Password')}
required
visibilityToggle
placeholder={t('e.g. ********')}
value={db?.ssh_tunnel?.private_key_password || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.private_key_password}
isValidating={isValidating}
data-test="ssh-tunnel-private_key_password-input"
iconRender={visible =>
iconRender={(visible: boolean) =>
visible ? (
<Tooltip title={t('Hide password.')}>
<Icons.EyeInvisibleOutlined />

View File

@@ -26,7 +26,6 @@ import {
userEvent,
within,
waitFor,
fireEvent,
} from 'spec/helpers/testing-library';
import { getExtensionsRegistry } from '@superset-ui/core';
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
@@ -436,11 +435,7 @@ describe('DatabaseModal', () => {
userEvent.click(selectInput);
// Simulate pasting text into the input
expect(() =>
fireEvent.paste(selectInput, {
clipboardData: { getData: () => 'post' },
}),
).not.toThrow();
expect(() => userEvent.paste(selectInput, 'post')).not.toThrow();
});
test('renders the "Basic" tab of SQL Alchemy form (step 2 of 2) correctly', async () => {
@@ -1215,25 +1210,31 @@ describe('DatabaseModal', () => {
);
expect(SSHTunnelServerAddressInput).toHaveValue('');
userEvent.type(SSHTunnelServerAddressInput, 'localhost');
expect(SSHTunnelServerAddressInput).toHaveValue('localhost');
await waitFor(() =>
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'),
);
const SSHTunnelServerPortInput = screen.getByTestId(
'ssh-tunnel-server_port-input',
);
expect(SSHTunnelServerPortInput).toHaveValue(null);
userEvent.type(SSHTunnelServerPortInput, '22');
expect(SSHTunnelServerPortInput).toHaveValue(22);
await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22));
const SSHTunnelUsernameInput = screen.getByTestId(
'ssh-tunnel-username-input',
);
expect(SSHTunnelUsernameInput).toHaveValue('');
userEvent.type(SSHTunnelUsernameInput, 'test');
expect(SSHTunnelUsernameInput).toHaveValue('test');
await waitFor(() =>
expect(SSHTunnelUsernameInput).toHaveValue('test'),
);
const SSHTunnelPasswordInput = screen.getByTestId(
'ssh-tunnel-password-input',
);
expect(SSHTunnelPasswordInput).toHaveValue('');
userEvent.type(SSHTunnelPasswordInput, 'pass');
expect(SSHTunnelPasswordInput).toHaveValue('pass');
await waitFor(() =>
expect(SSHTunnelPasswordInput).toHaveValue('pass'),
);
});
test('properly interacts with SSH Tunnel form textboxes', async () => {
@@ -1253,25 +1254,31 @@ describe('DatabaseModal', () => {
);
expect(SSHTunnelServerAddressInput).toHaveValue('');
userEvent.type(SSHTunnelServerAddressInput, 'localhost');
expect(SSHTunnelServerAddressInput).toHaveValue('localhost');
await waitFor(() =>
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'),
);
const SSHTunnelServerPortInput = screen.getByTestId(
'ssh-tunnel-server_port-input',
);
expect(SSHTunnelServerPortInput).toHaveValue(null);
userEvent.type(SSHTunnelServerPortInput, '22');
expect(SSHTunnelServerPortInput).toHaveValue(22);
await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22));
const SSHTunnelUsernameInput = screen.getByTestId(
'ssh-tunnel-username-input',
);
expect(SSHTunnelUsernameInput).toHaveValue('');
userEvent.type(SSHTunnelUsernameInput, 'test');
expect(SSHTunnelUsernameInput).toHaveValue('test');
await waitFor(() =>
expect(SSHTunnelUsernameInput).toHaveValue('test'),
);
const SSHTunnelPasswordInput = screen.getByTestId(
'ssh-tunnel-password-input',
);
expect(SSHTunnelPasswordInput).toHaveValue('');
userEvent.type(SSHTunnelPasswordInput, 'pass');
expect(SSHTunnelPasswordInput).toHaveValue('pass');
await waitFor(() =>
expect(SSHTunnelPasswordInput).toHaveValue('pass'),
);
});
test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => {
@@ -1366,7 +1373,10 @@ describe('DatabaseModal', () => {
}),
);
const textboxes = screen.getAllByRole('textbox');
// Wait for step 2 to render
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
const textboxes = await screen.findAllByRole('textbox');
const hostField = textboxes[0];
const portField = screen.getByRole('spinbutton');
const databaseNameField = textboxes[1];
@@ -1383,14 +1393,19 @@ describe('DatabaseModal', () => {
expect(connectButton).toBeDisabled();
userEvent.type(hostField, 'localhost');
userEvent.tab();
userEvent.type(portField, '5432');
userEvent.tab();
userEvent.type(databaseNameField, 'postgres');
userEvent.tab();
userEvent.type(usernameField, 'testdb');
userEvent.tab();
userEvent.type(passwordField, 'demoPassword');
userEvent.tab();
await waitFor(() => expect(connectButton).toBeEnabled());
expect(await screen.findByDisplayValue(/5432/i)).toBeInTheDocument();
await waitFor(() => expect(portField).toHaveValue(5432));
expect(hostField).toHaveValue('localhost');
expect(portField).toHaveValue(5432);
expect(databaseNameField).toHaveValue('postgres');
@@ -1399,10 +1414,48 @@ describe('DatabaseModal', () => {
expect(connectButton).toBeEnabled();
userEvent.click(connectButton);
// Verify that validation was called during the form interaction
// Note: With the optimized validation, redundant calls on the same db state are skipped
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(5);
).toBeGreaterThan(0);
});
});
test('does not fire redundant validation on blur when db has not changed', async () => {
setup();
userEvent.click(
await screen.findByRole('button', {
name: /postgresql/i,
}),
);
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
const textboxes = await screen.findAllByRole('textbox');
const hostField = textboxes[0];
// Type a value and blur - should trigger validation
userEvent.type(hostField, 'localhost');
userEvent.tab();
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(1);
});
// Blur again without changing the value - should NOT trigger another validation
userEvent.click(hostField);
userEvent.tab();
// Wait a tick to ensure no additional calls are made
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(1);
});
});
});

View File

@@ -617,6 +617,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
hasValidated,
setHasValidated,
] = useDatabaseValidation();
const lastValidatedDbSnapshotRef = useRef<string | null>(null);
const [hasConnectedDb, setHasConnectedDb] = useState<boolean>(false);
const [showCTAbtns, setShowCTAbtns] = useState(false);
const [dbName, setDbName] = useState('');
@@ -724,6 +725,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
const handleClearValidationErrors = useCallback(() => {
setValidationErrors(null);
setHasValidated(false);
lastValidatedDbSnapshotRef.current = null;
clearError();
}, [setValidationErrors, setHasValidated, clearError]);
@@ -800,6 +802,16 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
[onChange],
);
const handleTextChange = useCallback(
({ target }: { target: HTMLInputElement }) => {
onChange(ActionType.TextChange, {
name: target.name,
value: target.value,
});
},
[onChange],
);
const handleChangeWithValidation = useCallback(
(
actionType: ActionType,
@@ -811,6 +823,15 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
[onChange, handleClearValidationErrors],
);
const getBlurValidation = useCallback(() => {
const currentDbSnapshot = JSON.stringify(db);
if (currentDbSnapshot === lastValidatedDbSnapshotRef.current) {
return Promise.resolve([]);
}
lastValidatedDbSnapshotRef.current = currentDbSnapshot;
return getValidation(db);
}, [db, getValidation]);
const onClose = () => {
setDB({ type: ActionType.Reset });
setHasConnectedDb(false);
@@ -1796,7 +1817,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
name: target.name,
value: target.value,
});
handleClearValidationErrors();
}}
setSSHTunnelLoginMethod={(method: AuthType) =>
setDB({
@@ -1804,6 +1824,9 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
payload: { login_method: method },
})
}
isValidating={isValidating}
validationErrors={validationErrors}
getValidation={getBlurValidation}
/>
);
@@ -1872,13 +1895,8 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
});
}}
onParametersChange={handleParametersChange}
onChange={({ target }: { target: HTMLInputElement }) =>
handleChangeWithValidation(ActionType.TextChange, {
name: target.name,
value: target.value,
})
}
getValidation={() => getValidation(db)}
onChange={handleTextChange}
getValidation={getBlurValidation}
validationErrors={validationErrors}
getPlaceholder={getPlaceholder}
clearValidationErrors={handleClearValidationErrors}

View File

@@ -822,13 +822,6 @@ export function useDatabaseValidation() {
const getValidation = useCallback(
async (database: Partial<DatabaseObject> | null, onCreate = false) => {
if (database?.parameters?.ssh) {
setValidationErrors(null);
setIsValidating(false);
setHasValidated(true);
return Promise.resolve([]);
}
setIsValidating(true);
try {
@@ -866,6 +859,19 @@ export function useDatabaseValidation() {
return acc;
}
if (extra?.ssh_tunnel) {
acc.ssh_tunnel = {
...acc.ssh_tunnel,
...Object.fromEntries(
(extra.missing ?? []).map((field: string) => [
field,
'This is a required field',
]),
),
};
return acc;
}
if (extra?.invalid) {
extra.invalid.forEach((field: string) => {
acc[field] = message;

View File

@@ -19,6 +19,7 @@ from typing import Any, Optional
from flask_babel import gettext as __
from superset import is_feature_enabled
from superset.commands.base import BaseCommand
from superset.commands.database.exceptions import (
DatabaseOfflineError,
@@ -26,6 +27,10 @@ from superset.commands.database.exceptions import (
InvalidEngineError,
InvalidParametersError,
)
from superset.commands.database.ssh_tunnel.exceptions import (
SSHTunnelDatabasePortError,
SSHTunnelingNotEnabledError,
)
from superset.daos.database import DatabaseDAO
from superset.databases.utils import make_url_safe
from superset.db_engine_specs import get_engine_spec
@@ -42,7 +47,7 @@ class ValidateDatabaseParametersCommand(BaseCommand):
self._properties = properties.copy()
self._model: Optional[Database] = None
def run(self) -> None:
def run(self) -> None: # noqa: C901
self.validate()
engine = self._properties["engine"]
@@ -50,6 +55,8 @@ class ValidateDatabaseParametersCommand(BaseCommand):
if engine in BYPASS_VALIDATION_ENGINES:
# Skip engines that are only validated onCreate
# But still validate database_name uniqueness
self._validate_database_name()
return
engine_spec = get_engine_spec(engine, driver)
@@ -65,8 +72,17 @@ class ValidateDatabaseParametersCommand(BaseCommand):
),
)
# perform initial validation
# perform initial validation (host, port, database, username)
errors = engine_spec.validate_parameters(self._properties) # type: ignore
# Collect database_name errors along with parameter errors
if database_name_error := self._get_database_name_error():
errors.append(database_name_error)
# Collect SSH tunnel errors
ssh_tunnel_errors = self._get_ssh_tunnel_errors()
errors.extend(ssh_tunnel_errors)
if errors:
event_logger.log_with_context(action="validation_error", engine=engine)
raise InvalidParametersError(errors)
@@ -138,6 +154,101 @@ class ValidateDatabaseParametersCommand(BaseCommand):
),
)
def validate(self) -> None:
def _load_model(self) -> None:
"""Load the existing database model if updating."""
if (database_id := self._properties.get("id")) is not None:
self._model = DatabaseDAO.find_by_id(database_id)
def _get_database_name_error(self) -> Optional[SupersetError]:
"""Check for duplicate database name and return error if found."""
database_id = self._properties.get("id")
if database_name := self._properties.get("database_name"):
is_unique = (
DatabaseDAO.validate_update_uniqueness(database_id, database_name)
if database_id is not None
else DatabaseDAO.validate_uniqueness(database_name)
)
if not is_unique:
return SupersetError(
message=__("A database with the same name already exists."),
error_type=SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR,
level=ErrorLevel.ERROR,
extra={"invalid": ["database_name"]},
)
return None
def _validate_database_name(self) -> None:
"""Check for duplicate database name and raise if found."""
if error := self._get_database_name_error():
raise InvalidParametersError([error])
def validate(self) -> None:
"""Load the model and validate SSH tunnel if enabled."""
self._load_model()
self._validate_ssh_tunnel()
def _validate_ssh_tunnel(self) -> None:
"""Validate SSH tunnel configuration if enabled."""
ssh_tunnel = self._properties.get("ssh_tunnel")
if ssh_tunnel:
if not is_feature_enabled("SSH_TUNNELING"):
raise SSHTunnelingNotEnabledError()
# Check if port is provided (required for SSH tunneling)
parameters = self._properties.get("parameters", {})
if not parameters.get("port"):
raise SSHTunnelDatabasePortError()
def _get_ssh_tunnel_errors(self) -> list[SupersetError]:
"""Validate SSH tunnel fields and return list of errors."""
errors: list[SupersetError] = []
ssh_tunnel = self._properties.get("ssh_tunnel") or {}
parameters = self._properties.get("parameters", {})
# Check if SSH is enabled via parameters.ssh flag
ssh_enabled = parameters.get("ssh", False)
# Only validate SSH tunnel if SSH is enabled or ssh_tunnel is provided
if not ssh_enabled and not ssh_tunnel:
return errors
# Required fields
required_fields = ["server_address", "server_port", "username"]
missing = [f for f in required_fields if not ssh_tunnel.get(f)]
if missing:
errors.append(
SupersetError(
message=__("One or more parameters are missing: %(missing)s"),
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={"missing": missing, "ssh_tunnel": True},
)
)
# Either password or private_key is required
has_password = bool(ssh_tunnel.get("password"))
has_private_key = bool(ssh_tunnel.get("private_key"))
if not has_password and not has_private_key:
errors.append(
SupersetError(
message=__("Must provide credentials for the SSH Tunnel"),
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={"missing": ["password"], "ssh_tunnel": True},
)
)
# If private_key is provided, private_key_password is required
if has_private_key and not ssh_tunnel.get("private_key_password"):
errors.append(
SupersetError(
message=__("One or more parameters are missing: %(missing)s"),
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={"missing": ["private_key_password"], "ssh_tunnel": True},
)
)
return errors

View File

@@ -443,6 +443,24 @@ class DatabaseValidateParametersSchema(Schema):
required=True,
metadata={"description": configuration_method_description},
)
ssh_tunnel = fields.Nested("DatabaseSSHTunnelValidation", allow_none=True)
class DatabaseSSHTunnelValidation(Schema):
"""SSH Tunnel schema for validation.
Allows partial data without strict authentication requirements.
"""
id = fields.Integer(
allow_none=True, metadata={"description": "SSH Tunnel ID (for updates)"}
)
server_address = fields.String(allow_none=True)
server_port = fields.Integer(allow_none=True)
username = fields.String(allow_none=True)
password = fields.String(required=False, allow_none=True)
private_key = fields.String(required=False, allow_none=True)
private_key_password = fields.String(required=False, allow_none=True)
class DatabaseSSHTunnel(Schema):

File diff suppressed because it is too large Load Diff