diff --git a/packages/sqle/src/locale/en-US/dataSourceComparison.ts b/packages/sqle/src/locale/en-US/dataSourceComparison.ts new file mode 100644 index 000000000..a347f8370 --- /dev/null +++ b/packages/sqle/src/locale/en-US/dataSourceComparison.ts @@ -0,0 +1,18 @@ +// Only the keys that participate in cross-locale switching are translated +// here; other dataSourceComparison keys fall back to zh-CN via i18next's +// per-key fallback (fallbackLng = zh-CN). The drop-create warning banner +// MUST display in the user's selected language because it surfaces data +// loss risk; relying on zh-CN fallback would defeat the i18n contract. +// eslint-disable-next-line import/no-anonymous-default-export +export default { + entry: { + modifiedSqlDrawer: { + dropCreateWarningBanner: + 'Modify SQL contains destructive operations. Please review before execution.', + dropCreateWarningTable: + 'Data loss risk; table will be dropped and recreated.', + dropCreateWarningView: + 'View will be recreated; downstream queries depending on this view may be affected.' + } + } +}; diff --git a/packages/sqle/src/locale/en-US/index.ts b/packages/sqle/src/locale/en-US/index.ts index cbd01cc81..2e356e3b9 100644 --- a/packages/sqle/src/locale/en-US/index.ts +++ b/packages/sqle/src/locale/en-US/index.ts @@ -28,6 +28,7 @@ import pipelineConfiguration from './pipelineConfiguration'; import versionManagement from './versionManagement'; import sqlInsights from './sqlInsights'; import globalDashboard from './globalDashboard'; +import dataSourceComparison from './dataSourceComparison'; // eslint-disable-next-line import/no-anonymous-default-export export default { @@ -61,6 +62,7 @@ export default { pipelineConfiguration, versionManagement, sqlInsights, - globalDashboard + globalDashboard, + dataSourceComparison } }; diff --git a/packages/sqle/src/locale/zh-CN/dataSourceComparison.ts b/packages/sqle/src/locale/zh-CN/dataSourceComparison.ts index 68d50c8a1..cf522ae31 100644 --- a/packages/sqle/src/locale/zh-CN/dataSourceComparison.ts +++ b/packages/sqle/src/locale/zh-CN/dataSourceComparison.ts @@ -53,7 +53,11 @@ export default { } }, modifiedSqlDrawer: { - title: '变更SQL语句信息' + title: '变更SQL语句信息', + dropCreateWarningBanner: + '变更SQL中含数据破坏性操作,请人工确认后再执行。', + dropCreateWarningTable: '数据将丢失;表将被删除并重建。', + dropCreateWarningView: '视图将被重建;依赖该视图的下游查询可能受影响。' }, modifiedSqlAuditResult: { cardTitle: '变更语句' diff --git a/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/__tests__/ModifiedSqlDrawer.test.tsx b/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/__tests__/ModifiedSqlDrawer.test.tsx index 414610913..1c1d77deb 100644 --- a/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/__tests__/ModifiedSqlDrawer.test.tsx +++ b/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/__tests__/ModifiedSqlDrawer.test.tsx @@ -8,6 +8,10 @@ import { mockUsePermission } from '@actiontech/shared/lib/testUtil/mockHook/mock import { getBySelector } from '@actiontech/shared/lib/testUtil/customQuery'; import { mockProjectInfo } from '@actiontech/shared/lib/testUtil/mockHook/data'; import { compressToEncodedURIComponent } from 'lz-string'; +import { IDatabaseDiffModifySQL } from '@actiontech/shared/lib/api/sqle/service/common'; +import i18n from 'i18next'; +import enUSDataSourceComparison from '../../../../locale/en-US/dataSourceComparison'; +import zhCNDataSourceComparison from '../../../../locale/zh-CN/dataSourceComparison'; describe('ModifiedSqlDrawer', () => { beforeEach(() => { @@ -119,4 +123,152 @@ describe('ModifiedSqlDrawer', () => { customRender(true); expect(screen.queryByText('生成变更工单')).not.toBeInTheDocument(); }); + + // --------------------------------------------------------------------------- + // UI-3 (compat-RISK-6): WARNING banner + per-line highlight coverage. + // + // map-case fixtures: every scenario stuffs `databaseDiffModifiedSqlInfos` + // with a hand-crafted `modify_sqls` list so the production code's WARNING + // detection (`anySqlHasWarning` + `sqlHasWarningLine`) walks the same input + // path it would in real life. We never assert against translated strings to + // avoid coupling to the locale wording — the data-testid contract is the + // stable surface for both banner and highlight. + // --------------------------------------------------------------------------- + const buildDiffInfos = ( + sqls: string[], + schemaName = 'dms' + ): IDatabaseDiffModifySQL[] => [ + { + schema_name: schemaName, + modify_sqls: sqls.map((sql) => ({ sql_statement: sql })) + } + ]; + + const renderWithSqls = (sqls: string[]) => + sqleSuperRender( + + ); + + it('should render the drop-create warning banner when a SQL statement contains -- WARNING comment', () => { + renderWithSqls([ + `-- WARNING: data loss risk; table will be dropped and recreated.\nDROP TABLE IF EXISTS audit_files;\nCREATE TABLE audit_files (id BIGINT);` + ]); + + expect( + screen.getByTestId('modified-sql-drop-create-warning-banner') + ).toBeInTheDocument(); + }); + + it('should add a warning-highlight-line class to every -- WARNING line inside the SQL preview', () => { + renderWithSqls([ + `-- WARNING: data loss risk; table will be dropped and recreated.\nDROP TABLE IF EXISTS audit_files;\nCREATE TABLE audit_files (id BIGINT);` + ]); + + const highlightContainer = screen.getByTestId('warning-highlighted-sql'); + expect(highlightContainer).toBeInTheDocument(); + const warningLineNodes = highlightContainer.querySelectorAll( + '.warning-highlight-line' + ); + expect(warningLineNodes.length).toBe(1); + expect(warningLineNodes[0].textContent).toMatch(/-- WARNING:/); + }); + + it('should not render the banner nor the warning highlight container when no SQL contains a -- WARNING comment', () => { + renderWithSqls([ + `ALTER TABLE audit_files ADD COLUMN status VARCHAR(32);`, + `ALTER TABLE audit_files ADD COLUMN created_at DATETIME;` + ]); + + expect( + screen.queryByTestId('modified-sql-drop-create-warning-banner') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('warning-highlighted-sql') + ).not.toBeInTheDocument(); + // Sanity: SQL lines still render but never carry the highlight class. + expect(document.querySelectorAll('.warning-highlight-line').length).toBe(0); + }); + + it('should render the drop-create warning banner only once even when multiple modify SQLs carry -- WARNING comments', () => { + renderWithSqls([ + `-- WARNING: view will be recreated; downstream queries may be affected.\nDROP VIEW IF EXISTS v_audit_summary;\nCREATE VIEW v_audit_summary AS SELECT id FROM audit_files;`, + `-- WARNING: data loss risk; table will be dropped and recreated.\nDROP TABLE IF EXISTS audit_files;\nCREATE TABLE audit_files (id BIGINT, status VARCHAR(32));` + ]); + + expect( + screen.queryAllByTestId('modified-sql-drop-create-warning-banner').length + ).toBe(1); + // Both SQL fragments must keep their own highlight container — banner + // dedup happens at the drawer level, not by collapsing per-fragment + // visual cues. + expect(screen.queryAllByTestId('warning-highlighted-sql').length).toBe(2); + expect(document.querySelectorAll('.warning-highlight-line').length).toBe(2); + }); + + // Case 4 — i18n switching: banner copy MUST follow the active language. + // jest-setup only seeds zh-CN; we add the en-US bundle on the fly so the + // test can flip to English and assert the literal differs from Chinese. + describe('locale switching for the WARNING banner copy', () => { + beforeAll(() => { + i18n.addResourceBundle( + 'en-US', + 'translation', + { dataSourceComparison: enUSDataSourceComparison }, + true, + true + ); + }); + + afterEach(() => { + // restore default zh-CN so the rest of the test file keeps using the + // language jest-setup expects + i18n.changeLanguage('zh-CN'); + }); + + it('should render the WARNING banner copy according to the active language (zh-CN vs en-US)', async () => { + const sqls = [ + `-- WARNING: data loss risk; table will be dropped and recreated.\nDROP TABLE IF EXISTS audit_files;\nCREATE TABLE audit_files (id BIGINT);` + ]; + + // ----- zh-CN ----- + await i18n.changeLanguage('zh-CN'); + const zhView = renderWithSqls(sqls); + const zhBanner = zhView.getByTestId( + 'modified-sql-drop-create-warning-banner' + ); + const zhCopy = + zhCNDataSourceComparison.entry.modifiedSqlDrawer + .dropCreateWarningBanner; + expect(zhBanner).toHaveTextContent(zhCopy); + zhView.unmount(); + + // ----- en-US ----- + await i18n.changeLanguage('en-US'); + const enView = renderWithSqls(sqls); + const enBanner = enView.getByTestId( + 'modified-sql-drop-create-warning-banner' + ); + const enCopy = + enUSDataSourceComparison.entry.modifiedSqlDrawer + .dropCreateWarningBanner; + expect(enBanner).toHaveTextContent(enCopy); + + // sanity — the two locales must actually produce different copy or the + // test would always pass on either branch. + expect(zhCopy).not.toEqual(enCopy); + }); + }); }); diff --git a/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlAuditResult/List.tsx b/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlAuditResult/List.tsx index a4bdd86fb..d9f82e930 100644 --- a/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlAuditResult/List.tsx +++ b/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlAuditResult/List.tsx @@ -5,9 +5,14 @@ import { SqlAuditResultCollapseStyleWrapper } from '../ComparisonTreeNode/Compar import AuditResult from '../SqlAuditResult'; import { ModifiedSqlAuditResultInfoStyleWrapper, - ModifiedSqlAuditResultTitleStyleWrapper + ModifiedSqlAuditResultTitleStyleWrapper, + WarningHighlightedSqlStyleWrapper } from './style'; import { useTranslation } from 'react-i18next'; +import { + isWarningLine, + sqlHasWarningLine +} from '../ModifiedSqlDrawer/warningSql'; type Props = { dataSource?: ISQLStatementWithAuditResult[]; @@ -17,6 +22,35 @@ type Props = { auditError?: string; }; +// Renders a SQL string verbatim, line by line, while attaching a +// `warning-highlight-line` className to every `-- WARNING:` line. Used in +// place of SQLRenderer when WARNING annotations are present so the warning +// rows visibly stand out without depending on SQLRenderer's internal HTML. +const SqlWithWarningHighlight: React.FC<{ sql: string }> = ({ sql }) => { + const lines = sql.split(/\r?\n/); + return ( + + {lines.map((line, lineIndex) => ( + + {line === '' ? ' ' : line} + + ))} + + ); +}; + const ModifiedSqlAuditResultList: React.FC = ({ dataSource, instanceType, @@ -34,6 +68,8 @@ const ModifiedSqlAuditResultList: React.FC = ({ split={false} dataSource={dataSource} renderItem={(item, index) => { + const sqlText = item.sql_statement ?? ''; + const containsWarning = sqlHasWarningLine(sqlText); return ( @@ -45,7 +81,11 @@ const ModifiedSqlAuditResultList: React.FC = ({ - + {containsWarning ? ( + + ) : ( + + )} diff --git a/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlAuditResult/style.ts b/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlAuditResult/style.ts index 12f09518a..f656fd2a6 100644 --- a/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlAuditResult/style.ts +++ b/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlAuditResult/style.ts @@ -20,3 +20,30 @@ export const ModifiedSqlAuditResultTitleStyleWrapper = styled('div')` color: ${({ theme }) => theme.sharedTheme.uiToken.colorPrimary}; } `; + +export const WarningHighlightedSqlStyleWrapper = styled('pre')` + margin: 0; + padding: 8px 12px; + background: ${({ theme }) => theme.sharedTheme.uiToken.colorBgBase}; + border: 1px solid ${({ theme }) => theme.sharedTheme.uiToken.colorBorder}; + border-radius: 4px; + font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; + font-size: 12px; + line-height: 20px; + white-space: pre; + overflow-x: auto; + + .code-line { + display: block; + min-height: 20px; + } + + .warning-highlight-line { + background: ${({ theme }) => theme.sharedTheme.uiToken.colorWarningBgHover}; + color: ${({ theme }) => theme.sharedTheme.uiToken.colorWarning}; + border-left: 3px solid + ${({ theme }) => theme.sharedTheme.uiToken.colorWarning}; + padding-left: 6px; + font-weight: 500; + } +`; diff --git a/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlDrawer/index.tsx b/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlDrawer/index.tsx index 60356f438..9e69e14ee 100644 --- a/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlDrawer/index.tsx +++ b/packages/sqle/src/page/DataSourceComparison/ComparisonEntry/component/ModifiedSqlDrawer/index.tsx @@ -1,13 +1,14 @@ import { BasicDrawer } from '@actiontech/dms-kit'; import { IDatabaseDiffModifySQL } from '@actiontech/shared/lib/api/sqle/service/common'; -import { Spin } from 'antd'; +import { Alert, Spin } from 'antd'; import { useTranslation } from 'react-i18next'; import { SelectedInstanceInfo } from '../../index.type'; import { useCurrentProject } from '@actiontech/shared/lib/features'; import { CreateWorkflowForModifiedSqlAction } from '../../actions'; import { IGenDatabaseDiffModifySQLsV1Params } from '@actiontech/shared/lib/api/sqle/service/database_comparison/index.d'; import ModifiedSqlAuditResult from '../ModifiedSqlAuditResult'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import { anySqlHasWarning } from './warningSql'; type Props = { open: boolean; onClose: () => void; @@ -36,6 +37,18 @@ const ModifiedSqlDrawer: React.FC = ({ onClose(); setAuditResultCollapseActiveKeys([]); }; + // Surface the drawer-level warning banner exactly once when ANY modify SQL + // across ANY schema contains a `-- WARNING:` line. The banner is independent + // of dbType: drivers emit WARNING lines whenever a non-destructive ALTER is + // not possible (e.g. Hive DROP+CREATE fallback, VIEW recreation). + const hasAnyWarningSql = useMemo(() => { + if (!databaseDiffModifiedSqlInfos) { + return false; + } + return databaseDiffModifiedSqlInfos.some((schemaInfo) => + anySqlHasWarning(schemaInfo.modify_sqls) + ); + }, [databaseDiffModifiedSqlInfos]); return ( = ({ })} > + {hasAnyWarningSql && ( + + )} { + if (!sql) { + return false; + } + return sql.split(/\r?\n/).some((line) => isWarningLine(line)); +}; + +/** + * Returns true when the trimmed line starts with the WARNING marker. + */ +export const isWarningLine = (line: string): boolean => { + return line.trimStart().startsWith(WARNING_LINE_PREFIX); +}; + +/** + * Aggregates whether any SQL statement in the given iterable contains a + * WARNING marker. Used by ModifiedSqlDrawer to decide whether to show the + * top banner exactly once. + */ +export const anySqlHasWarning = ( + sqls: Array<{ sql_statement?: string } | undefined> | undefined +): boolean => { + if (!sqls || sqls.length === 0) { + return false; + } + return sqls.some((item) => sqlHasWarningLine(item?.sql_statement)); +};