From 9c635cac6ded9067688a007a614ee45a9e898bec Mon Sep 17 00:00:00 2001 From: ecmadao Date: Mon, 11 May 2026 21:15:28 +0800 Subject: [PATCH 001/127] fix: sql editor issues (#20293) * fix: sql editor issues * fix: sql editor issues --- .../src/react/components/DataExportButton.tsx | 16 +++++++++--- .../AsidePanel/ActionBarTabItem.tsx | 13 +++++++++- .../components/sql-editor/Panels/Panels.tsx | 15 ++++++++++- .../Panels/common/SchemaSelectToolbar.tsx | 4 ++- .../ResultView/VirtualDataTable.tsx | 26 +++++++++---------- .../react/components/sql-editor/TabList.tsx | 4 +-- frontend/src/react/components/ui/combobox.tsx | 15 +++++++++-- 7 files changed, 70 insertions(+), 23 deletions(-) diff --git a/frontend/src/react/components/DataExportButton.tsx b/frontend/src/react/components/DataExportButton.tsx index 4bd46cda9b67bb..84812004128528 100644 --- a/frontend/src/react/components/DataExportButton.tsx +++ b/frontend/src/react/components/DataExportButton.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; @@ -198,12 +199,21 @@ export function DataExportButton({ setPassword(""); }, [defaultLimit, supportFormats]); - // Reset form whenever the drawer opens, mirroring the Vue watcher. + // Reset form whenever the drawer transitions to OPEN. Depending on + // `resetForm` here would re-fire the reset on every parent re-render + // because `supportFormats` typically arrives as a fresh inline array + // (e.g. `supportFormats={[CSV, JSON, SQL, XLSX]}` from + // `SingleResultView`), which churns `resetForm`'s identity and would + // clobber the user's just-changed `limit` / `format` / `password`. + // Drive the reset off `showDrawer` alone via a ref to the latest + // `resetForm`. + const resetFormRef = useRef(resetForm); + resetFormRef.current = resetForm; useEffect(() => { if (showDrawer) { - resetForm(); + resetFormRef.current(); } - }, [showDrawer, resetForm]); + }, [showDrawer]); // Clear password each time the password dialog opens. useEffect(() => { diff --git a/frontend/src/react/components/sql-editor/AsidePanel/ActionBarTabItem.tsx b/frontend/src/react/components/sql-editor/AsidePanel/ActionBarTabItem.tsx index f387c1beadf287..5c919bb3752924 100644 --- a/frontend/src/react/components/sql-editor/AsidePanel/ActionBarTabItem.tsx +++ b/frontend/src/react/components/sql-editor/AsidePanel/ActionBarTabItem.tsx @@ -58,9 +58,20 @@ export function ActionBarTabItem({ action, disabled }: Props) { active && "bg-accent/10 text-accent hover:bg-accent/15" )} > + {/* + Several schema icons (`FunctionIcon`, `ProcedureIcon`, + `ViewIcon`, `SequenceIcon`, `PackageIcon`) hardcode `text-gray-400` + / `text-gray-500` on themselves — and in `ViewIcon`'s case on a + nested `` — to look de-emphasized inside the schema tree. + In the ActionBar rail those same icons read as a navigation + control, not as disabled. The `[&_*]:!text-current` selector + forces every descendant element to inherit this span's color + (`text-main` when inactive, the button's `text-accent` when + active), beating the icons' hardcoded gray. + */} diff --git a/frontend/src/react/components/sql-editor/Panels/Panels.tsx b/frontend/src/react/components/sql-editor/Panels/Panels.tsx index fc77791dfd749a..e02130a84c0f61 100644 --- a/frontend/src/react/components/sql-editor/Panels/Panels.tsx +++ b/frontend/src/react/components/sql-editor/Panels/Panels.tsx @@ -196,7 +196,20 @@ export function Panels() {
- + {/* + Wrap the chooser so it's the lone child of its own + flex container. The chooser uses + `[&:not(:last-child)]:border-r-0` / + `first:rounded-l-xs last:rounded-r-xs` to share borders + with adjacent buttons in a button-group context. Here + the next sibling is a separate Select primitive (not + part of the group), so without this wrapper the chooser + loses its right border and only gets left-rounded + corners. + */} +
+ +
diff --git a/frontend/src/react/components/sql-editor/Panels/common/SchemaSelectToolbar.tsx b/frontend/src/react/components/sql-editor/Panels/common/SchemaSelectToolbar.tsx index e9cc92bb173798..cb56d391d842b5 100644 --- a/frontend/src/react/components/sql-editor/Panels/common/SchemaSelectToolbar.tsx +++ b/frontend/src/react/components/sql-editor/Panels/common/SchemaSelectToolbar.tsx @@ -49,7 +49,9 @@ export function SchemaSelectToolbar() { if (typeof value === "string") setSchema(value); }} > - + {/* Use `h-8` to align with the sibling `DatabaseChooser` (also h-8); + the default `size="sm"` trigger is `h-7` and looked shorter. */} + diff --git a/frontend/src/react/components/sql-editor/ResultView/VirtualDataTable.tsx b/frontend/src/react/components/sql-editor/ResultView/VirtualDataTable.tsx index f1d3cab5e5eeab..bf48e583e1078e 100644 --- a/frontend/src/react/components/sql-editor/ResultView/VirtualDataTable.tsx +++ b/frontend/src/react/components/sql-editor/ResultView/VirtualDataTable.tsx @@ -266,11 +266,11 @@ export const VirtualDataTable = forwardRef< return (
{/* Header */}
{/* Index header */} @@ -306,10 +306,10 @@ export const VirtualDataTable = forwardRef< } onClick={(e) => handleSelectColumn(e, columnIndex)} className={cn( - "px-3 py-1.5 min-w-8 text-left text-xs font-medium text-control-light tracking-wider", + "px-3 py-1.5 min-w-8 text-left text-xs font-medium text-control-light dark:text-gray-300 tracking-wider", !selectionDisabled && - "cursor-pointer hover:bg-control-bg-hover", - isSelected && "bg-accent/10!" + "cursor-pointer hover:bg-control-bg-hover dark:hover:bg-gray-800", + isSelected && "bg-accent/10! dark:bg-accent/40!" )} >
@@ -392,9 +392,9 @@ export const VirtualDataTable = forwardRef< {/* Index cell */}
handleSelectRow(e, rowIndex)} className={cn( - "absolute inset-y-0 left-0 w-3 cursor-pointer bg-accent/5 hover:bg-accent/10", - rowSelected && "bg-accent/20!" + "absolute inset-y-0 left-0 w-3 cursor-pointer bg-accent/5 hover:bg-accent/10 dark:bg-white/10 dark:hover:bg-accent/40", + rowSelected && "bg-accent/20! dark:bg-accent/40!" )} /> )} @@ -431,12 +431,12 @@ export const VirtualDataTable = forwardRef<
tab.id)} strategy={horizontalListSortingStrategy} > -
+
{tabs.map((tab, index) => ( @@ -414,7 +419,13 @@ export function Combobox(props: ComboboxProps) { {/* Trigger */}
Date: Mon, 11 May 2026 21:44:40 +0800 Subject: [PATCH 002/127] fix: sql editor ux detail (#20294) * fix: sql editor issues * fix: sql editor issues * fix: sql editor ux detail --- frontend/src/react/components/DataExportButton.tsx | 8 ++------ .../components/sql-editor/SchemaPane/SchemaPane.tsx | 11 ++++++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/frontend/src/react/components/DataExportButton.tsx b/frontend/src/react/components/DataExportButton.tsx index 84812004128528..823fadf35276c1 100644 --- a/frontend/src/react/components/DataExportButton.tsx +++ b/frontend/src/react/components/DataExportButton.tsx @@ -355,6 +355,7 @@ export function DataExportButton({ value={limit} onChange={setLimit} maximum={presetMax} + className="h-9" />
@@ -390,16 +391,11 @@ export function DataExportButton({
- + } + > + {t("schema-editor.self")} + + {open && ( + onOpenChange(false)} + /> + )} + + + ); + } + ``` + + Note: `useState` must be imported — the existing `import { useCallback, useEffect, useRef, useState } from "react";` line already has it. + +- [ ] **Step 4: Type-check and lint** + + Run: `pnpm --dir frontend type-check` + Expected: PASS. + + Run: `pnpm --dir frontend check` + Expected: PASS. The layering scanner (`check-react-layering.mjs`) should not flag this change — no new portal targets, no raw `z-index`, no body portals. + +- [ ] **Step 5: Manual sanity check** + + Run: `pnpm --dir frontend dev` + Open a plan in the browser. Click "Schema editor" in the statement section toolbar. + Expected: + - Drawer opens at the same width as today (`xlarge`). + - The ⤢ button appears immediately left of the X in the header. Tooltip reads "Maximize". + - Click ⤢ — drawer expands to ~95% viewport, leaving a thin strip on the left. Icon flips to "Minimize" and tooltip reads "Restore". + - Click the icon again — drawer collapses back to `xlarge`. + - Click the left strip — sheet closes (matches scrim behavior). + - Close + reopen — drawer is back at `xlarge` (state reset). + +- [ ] **Step 6: Commit** + + ```bash + git add frontend/src/react/pages/project/plan-detail/components/SchemaEditorSheet.tsx \ + frontend/src/locales/ + git commit -m "feat(react): maximize toggle on plan-detail schema editor sheet" + ``` + +--- + +## Task 3: Shared `ColumnIcon` — fix the "C" mismatch (BYT-9473 item 1) + +**Files:** +- Create: `frontend/src/react/components/schema/icons.tsx` +- Modify: `frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx` +- Modify: `frontend/src/react/components/sql-editor/SchemaPane/TreeNode/icons.tsx` (re-export from shared module) + +- [ ] **Step 1: Create the shared icon module** + + Create `frontend/src/react/components/schema/icons.tsx` with: + + ```tsx + import { cn } from "@/react/lib/utils"; + + interface IconProps { + className?: string; + } + + /** + * Single-column variant of `lucide:columns-3` (the second internal gap line + * removed). Used in both Schema Editor and SQL Editor tree views so the two + * surfaces stay visually identical. + */ + export function ColumnIcon({ className }: IconProps) { + return ( + + + + + ); + } + ``` + + Note: this uses the semantic `text-control-light` token, not `text-gray-500`. AGENTS.md forbids raw colors in React UI. + +- [ ] **Step 2: Re-export from the SQL Editor's icons file** + + Open `frontend/src/react/components/sql-editor/SchemaPane/TreeNode/icons.tsx`. Replace the existing `ColumnIcon` definition (currently lines 129-151) with a re-export so the two surfaces share one source of truth: + + ```tsx + // ColumnIcon is shared with SchemaEditorLite; both surfaces must render + // identical icons so users don't see drift across editors. + export { ColumnIcon } from "@/react/components/schema/icons"; + ``` + + Remove the now-unused `cn` and `baseSize` references if no other icon in the file uses them. (`Check` is still in use for `CheckConstraintIcon` above — leave that.) + +- [ ] **Step 3: Swap `
C
` for `` in `AsideTree`** + + In `frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx`, add the import near the other component imports at the top: + + ```ts + import { ColumnIcon } from "@/react/components/schema/icons"; + ``` + + Then replace the `case "column"` branch in `NodeIcon` (currently lines 472-477): + + ```tsx + case "column": + return ; + ``` + + `cls` is the existing `"size-4 shrink-0"` constant a few lines above — keep it so sizing stays consistent with the other Lucide icons in this switch. + +- [ ] **Step 4: Type-check and lint** + + Run: `pnpm --dir frontend type-check` + Expected: PASS. + + Run: `pnpm --dir frontend check` + Expected: PASS. + +- [ ] **Step 5: Manual sanity check** + + Reopen the schema editor in the dev server. Expand a table. Expected: + - Each column row now shows the same single-column SVG used by SQL Editor (no more bold "C" text). + - Status colors on the row label remain (`text-success` for created, etc.) — they apply to the label, not the icon. + - Open the SQL Editor's schema pane in parallel and confirm column icons look identical. + +- [ ] **Step 6: Commit** + + ```bash + git add frontend/src/react/components/schema/icons.tsx \ + frontend/src/react/components/sql-editor/SchemaPane/TreeNode/icons.tsx \ + frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx + git commit -m "fix(schema-editor): use shared ColumnIcon to match SQL Editor" + ``` + +--- + +## Task 4: Convert tree status text into a Badge (BYT-9473 polish) + +**Files:** +- Modify: `frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx` + +- [ ] **Step 1: Add `Badge` import** + + In `AsideTree.tsx`, add: + + ```ts + import { Badge } from "@/react/components/ui/badge"; + ``` + +- [ ] **Step 2: Add a small `StatusBadge` helper** + + Just below the existing `statusClassName` helper (around line 512), add: + + ```tsx + // Single-letter badge to mark created / updated / dropped entries in the + // tree. Sits to the right of the label so it doesn't crowd the icon column + // and reads at a glance — text color alone was too quiet (BYT-9473). + function StatusBadge({ status }: { status: EditStatus }) { + if (status === "normal") return null; + const variant = + status === "created" + ? "success" + : status === "updated" + ? "warning" + : "error"; + const letter = + status === "created" ? "+" : status === "updated" ? "~" : "−"; + return ( + + {letter} + + ); + } + ``` + + Note: confirm `Badge` exposes `success`, `warning`, `error` variants by opening `frontend/src/react/components/ui/badge.tsx`. If it only exposes `default`, `secondary`, `destructive`, `outline`, use semantic class overrides via `className` instead — e.g. ``. Pick whichever route lands without modifying `badge.tsx`. + +- [ ] **Step 3: Render the badge in `NodeRenderer`** + + Replace the label span in `NodeRenderer` (currently lines 577-579): + + ```tsx + + {treeNode.label || "(empty)"} + + + ``` + + Keep `statusClassName(status)` on the label so the existing strike-through for `dropped` survives — the badge is additive, not a replacement. + +- [ ] **Step 4: Type-check and lint** + + Run: `pnpm --dir frontend type-check` + Expected: PASS. + + Run: `pnpm --dir frontend check` + Expected: PASS. + +- [ ] **Step 5: Manual sanity check** + + In the dev server, make any schema edit (add a table, drop a column). Expected: + - Created entries show a `+` badge in the tree, updated `~`, dropped `−`. + - Dropped entries still show the strike-through on the label. + - Normal entries show no badge (no visual change vs today). + +- [ ] **Step 6: Commit** + + ```bash + git add frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx + git commit -m "feat(schema-editor): badge created/updated/dropped tree entries" + ``` + +--- + +## Task 5: Convert `TableNameDialog` to inline `TableNamePopover` (BYT-9473 item 2) + +**Files:** +- Create: `frontend/src/react/components/SchemaEditorLite/Modals/TableNamePopover.tsx` +- Modify: `frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx` (swap call site) +- Delete: `frontend/src/react/components/SchemaEditorLite/Modals/TableNameDialog.tsx` (last task step, after wiring proves out) + +- [ ] **Step 1: Investigate the failure (5 minutes)** + + Before writing code, reproduce the BYT-9473 "new table not work" symptom locally. Open the schema editor → right-click a schema → "New table". Note exactly what fails (modal doesn't appear, input doesn't focus, Enter does nothing, Esc closes the sheet instead of the dialog, etc.). Record observations in the PR description later. + + This step is information-gathering — no code change. It confirms the root cause matches the spec's hypothesis (nested Base UI Dialog inside Sheet in the same `overlay` layer family). + +- [ ] **Step 2: Create the popover component** + + Create `frontend/src/react/components/SchemaEditorLite/Modals/TableNamePopover.tsx` with: + + ```tsx + import { create } from "@bufbuild/protobuf"; + import { useCallback, useState } from "react"; + import { useTranslation } from "react-i18next"; + import { Button } from "@/react/components/ui/button"; + import { Input } from "@/react/components/ui/input"; + import { + Popover, + PopoverContent, + } from "@/react/components/ui/popover"; + import { Engine } from "@/types/proto-es/v1/common_pb"; + import type { + Database, + DatabaseMetadata, + SchemaMetadata, + TableMetadata, + } from "@/types/proto-es/v1/database_service_pb"; + import { + ColumnMetadataSchema, + TableMetadataSchema, + } from "@/types/proto-es/v1/database_service_pb"; + import { getDatabaseEngine } from "@/utils"; + import { useSchemaEditorContext } from "../context"; + import { upsertColumnPrimaryKey } from "../core/edit"; + import { markUUID } from "../Panels/common"; + + interface Props { + open: boolean; + onClose: () => void; + /** Screen-space coordinates of the click that triggered the popover. + * The popover anchors to a virtual rect at this point so we don't depend + * on a DOM element that may have unmounted (e.g. the context menu item). + */ + anchorPoint: { x: number; y: number }; + db: Database; + database: DatabaseMetadata; + schema: SchemaMetadata; + table?: TableMetadata; + } + + const TABLE_NAME_REGEX = /^\S[\S ]*\S?$/; + + export function TableNamePopover({ + open, + onClose, + anchorPoint, + db, + database, + schema, + table, + }: Props) { + const { t } = useTranslation(); + const { tabs, editStatus, rebuildTree, rebuildEditStatus, scrollStatus } = + useSchemaEditorContext(); + const engine = getDatabaseEngine(db); + + const [tableName, setTableName] = useState(table?.name ?? ""); + const isCreateMode = !table; + + const isDuplicate = + tableName !== table?.name && + schema.tables.some((tt) => tt.name === tableName); + + const isValid = + tableName.length > 0 && TABLE_NAME_REGEX.test(tableName) && !isDuplicate; + + const handleConfirm = useCallback(() => { + if (!isValid) return; + + if (isCreateMode) { + const newTable = create(TableMetadataSchema, { + name: tableName, + columns: [], + indexes: [], + foreignKeys: [], + partitions: [], + comment: "", + }); + schema.tables.push(newTable); + editStatus.markEditStatus(db, { schema, table: newTable }, "created"); + + const defaultType = engine === Engine.POSTGRES ? "integer" : "int"; + const idColumn = create(ColumnMetadataSchema, { + name: "id", + type: defaultType, + nullable: false, + hasDefault: false, + default: "", + comment: "", + }); + markUUID(idColumn); + newTable.columns.push(idColumn); + editStatus.markEditStatus( + db, + { schema, table: newTable, column: idColumn }, + "created" + ); + upsertColumnPrimaryKey(engine, newTable, "id"); + + tabs.addTab({ + type: "table", + database: db, + metadata: { database, schema, table: newTable }, + }); + scrollStatus.queuePendingScrollToTable({ + db, + metadata: { database, schema, table: newTable }, + }); + rebuildTree(false); + } else { + table.name = tableName; + rebuildEditStatus(["tree"]); + } + + onClose(); + }, [ + isValid, + isCreateMode, + tableName, + schema, + editStatus, + db, + database, + engine, + tabs, + scrollStatus, + rebuildTree, + rebuildEditStatus, + table, + onClose, + ]); + + // Virtual anchor at the click point. Base UI's Positioner accepts an + // object with getBoundingClientRect(); we expose a 1×1 rect at (x, y). + const anchor = { + getBoundingClientRect: () => + ({ + width: 0, + height: 0, + x: anchorPoint.x, + y: anchorPoint.y, + top: anchorPoint.y, + left: anchorPoint.x, + right: anchorPoint.x, + bottom: anchorPoint.y, + toJSON() { + return this; + }, + }) as DOMRect, + }; + + return ( + !next && onClose()}> + +
+
+ {isCreateMode + ? t("schema-editor.actions.create-table") + : t("schema-editor.actions.rename-table")} +
+ setTableName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleConfirm(); + if (e.key === "Escape") onClose(); + }} + /> + {isDuplicate && ( +

+ {t("schema-editor.table.duplicate-name")} +

+ )} +
+ + +
+
+
+
+ ); + } + ``` + +- [ ] **Step 3: Swap the call site in `AsideTree`** + + In `AsideTree.tsx`: + + - Replace the import: + ```ts + import { TableNamePopover } from "../Modals/TableNamePopover"; + ``` + (was `import { TableNameDialog } from "../Modals/TableNameDialog";`) + + - The existing `tableNameModalCtx` state captures `{ db, database, schema, table? }`. Extend the context to also carry the click coordinates. Locate the right-click handler that opens the menu (search for `setMenuState` / `handleMenuSelect`). Where the "create-table" / "rename-table" branch sets `tableNameModalCtx`, also include `anchorPoint: { x: menuState.x, y: menuState.y }`. Update the `tableNameModalCtx` type accordingly: + + ```ts + type TableNameModalCtx = { + db: Database; + database: DatabaseMetadata; + schema: SchemaMetadata; + table?: TableMetadata; + anchorPoint: { x: number; y: number }; + }; + ``` + + - Replace the `` block (currently lines 410-419) with: + + ```tsx + {tableNameModalCtx && ( + setTableNameModalCtx(null)} + anchorPoint={tableNameModalCtx.anchorPoint} + db={tableNameModalCtx.db} + database={tableNameModalCtx.database} + schema={tableNameModalCtx.schema} + table={tableNameModalCtx.table} + /> + )} + ``` + +- [ ] **Step 4: Delete the old dialog** + + Delete `frontend/src/react/components/SchemaEditorLite/Modals/TableNameDialog.tsx`. + + ```bash + git rm frontend/src/react/components/SchemaEditorLite/Modals/TableNameDialog.tsx + ``` + +- [ ] **Step 5: Type-check and lint** + + Run: `pnpm --dir frontend type-check` + Expected: PASS. If the layering scanner complains about the new file, double-check that `PopoverContent` is the only portal target — it already mounts into `getLayerRoot("overlay")` via the shared primitive. + + Run: `pnpm --dir frontend check` + Expected: PASS. + +- [ ] **Step 6: Manual sanity check** + + Reopen the schema editor: + - Right-click a schema → "New table". Popover appears at the cursor; name input is autofocused; typing + Enter creates the table, opens its tab, scrolls to it. + - Right-click a table → "Rename". Popover appears prefilled with the current name; edit + Enter renames; the tree refreshes. + - Esc closes the popover but leaves the sheet open. + - Clicking outside the popover (but inside the sheet) dismisses the popover only — sheet stays. + +- [ ] **Step 7: Commit** + + ```bash + git add frontend/src/react/components/SchemaEditorLite/Modals/TableNamePopover.tsx \ + frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx + git commit -m "fix(schema-editor): convert table-name dialog to inline popover" + ``` + + Note: the `git rm` from Step 4 stages the deletion automatically; `git add` here covers the new file and the call-site change. + +--- + +## Task 6: Highlight new column on add (BYT-9473 item 3) + +**Files:** +- Modify: `frontend/src/assets/css/tailwind.css` +- Modify: `frontend/src/react/components/SchemaEditorLite/Panels/TableEditor.tsx` +- Modify: `frontend/src/react/components/SchemaEditorLite/Panels/TableColumnEditor.tsx` (apply the data attribute and focus the input — confirm exact filename when opening) + +- [ ] **Step 1: Add the row-flash keyframe** + + Open `frontend/src/assets/css/tailwind.css`. Locate the `@theme` / `@utility` sections (search for `@utility` to find existing ones). Add: + + ```css + @keyframes row-flash { + 0% { + background-color: color-mix(in oklch, var(--color-success) 18%, transparent); + } + 100% { + background-color: transparent; + } + } + + @utility animate-row-flash { + animation: row-flash 1.2s ease-out forwards; + } + ``` + + Use `color-mix` against `--color-success` so the flash respects the theme; `bg-success/10` directly does not apply through animation in Tailwind v4 without a custom keyframe. + +- [ ] **Step 2: Mark newly-added columns in `handleAddColumn`** + + In `frontend/src/react/components/SchemaEditorLite/Panels/TableEditor.tsx`, replace `handleAddColumn` (currently lines 69-86) with: + + ```tsx + const handleAddColumn = useCallback(() => { + const column = create(ColumnMetadataSchema, { + name: "", + type: "", + nullable: true, + hasDefault: false, + default: "", + comment: "", + }); + markUUID(column); + table.columns.push(column); + editStatus.markEditStatus(db, { schema, table, column }, "created"); + rebuildTree(false); + scrollStatus.queuePendingScrollToColumn({ + db, + metadata: { database, schema, table, column }, + }); + // Surface the new row visually. The flash + autofocus run in + // TableColumnEditor when it sees data-just-added on the row. + scrollStatus.queuePendingFocusForColumn?.({ + db, + metadata: { database, schema, table, column }, + }); + }, [db, database, schema, table, editStatus, rebuildTree, scrollStatus]); + ``` + + Note: if `queuePendingFocusForColumn` doesn't yet exist on `scrollStatus`, locate `scrollStatus` (likely in `frontend/src/react/components/SchemaEditorLite/context.ts` or a hook nearby) and add a small queue parallel to `queuePendingScrollToColumn`: + + ```ts + // Inside the scrollStatus state object: + pendingFocusColumnKey: null as string | null, + + queuePendingFocusForColumn(target: ColumnTarget) { + this.pendingFocusColumnKey = columnKey(target); + }, + + consumePendingFocusForColumn(target: ColumnTarget): boolean { + if (this.pendingFocusColumnKey !== columnKey(target)) return false; + this.pendingFocusColumnKey = null; + return true; + }, + ``` + + Use the existing `columnKey` helper that `queuePendingScrollToColumn` already uses (search the file for it). If the existing scrollStatus is a Zustand-like store with `set`, follow the same pattern. + +- [ ] **Step 3: Consume the flag in the column editor row** + + Open `frontend/src/react/components/SchemaEditorLite/Panels/TableColumnEditor.tsx` (verify exact filename — referenced via `import { TableColumnEditor } from "./TableColumnEditor";` in `TableEditor.tsx`). For each rendered column row: + + - Add a `useRef(null)` for the Name input on each row (if not already present). + - On mount of a row whose column matches the pending-focus key, call `consumePendingFocusForColumn`; if it returns `true`, focus the name input, set `data-just-added="true"` on the row, and add `animate-row-flash` to the row's `className`. + - Remove the attribute / class on `onAnimationEnd`. + + Minimal sketch (drop into the row component): + + ```tsx + const rowRef = useRef(null); + const nameInputRef = useRef(null); + const [justAdded, setJustAdded] = useState(false); + + useEffect(() => { + if (scrollStatus.consumePendingFocusForColumn({ db, metadata: { database, schema, table, column } })) { + setJustAdded(true); + // Defer focus to the next frame so the row is in the DOM. + requestAnimationFrame(() => nameInputRef.current?.focus()); + } + // We only want this on mount per row instance. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
setJustAdded(false)} + > + {/* …existing cells, with nameInputRef passed to the Name */} + + ); + ``` + + If `TableColumnEditor` renders rows via a virtualized list, apply the same pattern at whichever element actually represents a row in the DOM. + +- [ ] **Step 4: Type-check and lint** + + Run: `pnpm --dir frontend type-check` + Expected: PASS. + + Run: `pnpm --dir frontend check` + Expected: PASS. + +- [ ] **Step 5: Manual sanity check** + + In the dev server, open a table and click "Add column". Expected: + - The row appears, the editor scrolls to it (existing behavior). + - The row pulses a faint green for ~1.2s. + - The Name input is focused — you can immediately type the column name. + - Adding a second column does the same; previously-added rows do not re-flash. + +- [ ] **Step 6: Commit** + + ```bash + git add frontend/src/assets/css/tailwind.css \ + frontend/src/react/components/SchemaEditorLite/Panels/TableEditor.tsx \ + frontend/src/react/components/SchemaEditorLite/Panels/TableColumnEditor.tsx \ + frontend/src/react/components/SchemaEditorLite/context.ts + git commit -m "feat(schema-editor): flash and focus newly added columns" + ``` + +--- + +## Task 7: Toolbar density and empty-state polish + +**Files:** +- Modify: `frontend/src/react/pages/project/plan-detail/components/SchemaEditorSheet.tsx` +- Modify: `frontend/src/react/components/SchemaEditorLite/SchemaEditorLite.tsx` (or wherever the editor renders when no tables exist — verify on opening) + +- [ ] **Step 1: Tighten the body spacing in `SchemaEditorSheet`** + + In `frontend/src/react/pages/project/plan-detail/components/SchemaEditorSheet.tsx`, locate the body wrapper (currently `
` on line 212). Change: + + ```tsx +
+ ``` + + (`gap-y-3` → `gap-y-2`). Align the combobox row above the editor by giving the combobox `
` a fixed height: `
` — this keeps the baseline stable whether or not the row is present. + +- [ ] **Step 2: Add an empty-state for the editor panel** + + Open `frontend/src/react/components/SchemaEditorLite/SchemaEditorLite.tsx` and locate where the right panel (`EditorPanel`) renders. Find the path where the selected database has no tables and no views yet — likely a render that returns nothing or a bare empty `
`. Replace whatever is rendered in that branch with: + + ```tsx +
+
+ +

+ {t("schema-editor.empty-state.no-tables")} +

+

+ {t("schema-editor.empty-state.hint")} +

+
+
+ ``` + + Add the corresponding locale entries to `frontend/src/locales/en-US.json` under the existing `"schema-editor"` block (the block starts around line 1773): + + ```json + "empty-state": { + "no-tables": "No objects yet", + "hint": "Right-click a schema in the tree to create your first table." + } + ``` + + Import `Table2` from `lucide-react` and `useTranslation` from `react-i18next` if not already in this file. + +- [ ] **Step 3: Type-check and lint** + + Run: `pnpm --dir frontend type-check` + Expected: PASS. + + Run: `pnpm --dir frontend check` + Expected: PASS. + +- [ ] **Step 4: Manual sanity check** + + - Open a plan whose target database has at least one table — confirm there's no visual regression in the editor body; the combobox row should not jitter when switching databases. + - Open a plan whose target database has no tables yet — confirm the empty-state appears with the correct icon, title, and hint. + +- [ ] **Step 5: Commit** + + ```bash + git add frontend/src/react/pages/project/plan-detail/components/SchemaEditorSheet.tsx \ + frontend/src/react/components/SchemaEditorLite/SchemaEditorLite.tsx \ + frontend/src/locales/ + git commit -m "feat(schema-editor): tighten toolbar spacing and add empty state" + ``` + +--- + +## Task 8: Final verification + +**Files:** none — verification only. + +- [ ] **Step 1: Run the full frontend check suite** + + ```bash + pnpm --dir frontend fix + pnpm --dir frontend check + pnpm --dir frontend type-check + pnpm --dir frontend test + ``` + + Expected: all PASS. `fix` may auto-format a few files; if so, amend the most recent task's commit: + + ```bash + git add -u + git commit --amend --no-edit + ``` + +- [ ] **Step 2: End-to-end manual walkthrough** + + In the dev server, walk the full BYT-9473 acceptance set in a single session: + + 1. Open a plan, open the schema editor → drawer is `xlarge` width. + 2. Click ⤢ → drawer expands to ~95vw with the left strip visible. ⤢ again → back to `xlarge`. + 3. Click the left strip → sheet closes. Reopen → back at `xlarge` (state reset). + 4. Tree shows the new column SVG icon (matches SQL Editor); created/updated/dropped entries show their badges. + 5. Right-click a schema → "New table" → popover at cursor → name → Enter → table created, tab opened, tree updated. + 6. Click "Add column" → row scrolls into view, flashes green for ~1.2s, Name input is focused. + 7. Switch to a database with no tables → empty-state appears with hint. + 8. Esc closes the sheet from both `xlarge` and `huge` modes. + +- [ ] **Step 3: Compare against the pre-PR checklist** + + Open `docs/pre-pr-checklist.md` and run through it. Notable items for this change: + - No breaking changes (UI only). + - No composite-PK queries touched. + - No backend changes. + - i18n: all new strings live in `frontend/src/locales/`. + - SonarCloud properties: unchanged. + +- [ ] **Step 4: Push and open PR** + + ```bash + git push -u origin steven/byt-9473-schema-editor-display-not-work-properly + gh pr create --title "fix(schema-editor): maximize toggle + BYT-9473 polish" --body "$(cat <<'EOF' + ## Summary + - Plan-detail schema editor drawer gains a maximize toggle (xlarge ↔ 95vw) + - Replaces the bold "C" column icon with the shared SQL Editor `ColumnIcon` + - Converts the nested "New table" dialog to an inline popover (fixes nested-modal focus issue) + - Flashes + focuses newly added column rows so they're not silently appended + - Adds Badge markers + empty-state polish in the schema editor tree + + Closes BYT-9473. + + ## Test plan + - [ ] Drawer opens at xlarge; ⤢ toggles to 95vw; resets on each open + - [ ] Strip click closes the sheet + - [ ] Tree column rows render the shared `ColumnIcon` + - [ ] "New table" popover works from the schema context menu + - [ ] "Add column" — row scrolls in, flashes, Name input is focused + - [ ] Empty-state appears for a database with no tables + - [ ] `pnpm --dir frontend type-check && pnpm --dir frontend check && pnpm --dir frontend test` all pass + + 🤖 Generated with [Claude Code](https://claude.com/claude-code) + EOF + )" + ``` + +--- + +## Notes for the implementing engineer + +- **TDD posture for this plan:** the spec deliberately calls for no new automated tests (the schema editor has none today, and adding them is out of scope). Verification is via type-check, lint, the layering scanner, and the manual walkthrough at the end of each task and in Task 8. If a step surfaces a unit-testable invariant cheaply (e.g. the maximize state reset), feel free to add a test in `frontend/src/react/components/ui/sheet.test.tsx`-style — but don't block the plan on it. +- **Layering policy:** every overlay surface in this plan portals into the shared `overlay` family via the existing primitives (`Sheet`, `Popover`). Do not introduce raw `z-index`, raw body portals, or new layer roots. Run `pnpm --dir frontend check` after each task to keep the layering scanner happy. +- **Locale files:** the project usually mirrors keys across all locales under `frontend/src/locales/`. If a `pnpm` build fails because a key is missing from a non-English locale, copy the English fallback into the missing file rather than silently dropping the key. +- **Worktree:** if executing in an isolated worktree, it should have been created via `superpowers:using-git-worktrees`. Otherwise work in the `steven/byt-9473-schema-editor-display-not-work-properly` branch already named on the Linear issue. diff --git a/docs/superpowers/specs/2026-05-12-byt-9473-schema-editor-plan-detail-container-design.md b/docs/superpowers/specs/2026-05-12-byt-9473-schema-editor-plan-detail-container-design.md new file mode 100644 index 00000000000000..30407eb2afa413 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-byt-9473-schema-editor-plan-detail-container-design.md @@ -0,0 +1,262 @@ +# Schema Editor — Plan Detail Container Redesign + BYT-9473 Polish + +**Date:** 2026-05-12 +**Linear:** [BYT-9473](https://linear.app/bytebase/issue/BYT-9473/schema-editor-display-not-work-properly) +**Branch:** `steven/byt-9473-schema-editor-display-not-work-properly` + +## Summary + +Replace the fixed-width drawer that hosts the schema editor inside the plan +detail page with a drawer that can be maximized to ~95% viewport. Bundle the +four polish issues called out in BYT-9473 (column icon mismatch, broken "new +table" action, missing add-column highlight, general unfinished feel) into the +same change so the editor lands in a coherent state. + +The editor's *internals* (tree + table editor + DDL preview layout) are +explicitly out of scope. This is a container change plus targeted polish, not +a redesign of the schema editor itself. + +## Motivation + +Today `SchemaEditorSheet` opens a right-side `Sheet` at the `xlarge` width tier +(70rem ≈ 1120px). At that width the tree, table editor, and DDL preview +compete for horizontal space and the experience feels cramped — which is half +of what BYT-9473's "overall display not so polished" actually reports. The +other half is four concrete defects flagged in the same ticket. + +A wider drawer is the smallest change that resolves the cramped feeling +without (a) violating the AGENTS.md rule that resource editing uses Sheets, +(b) restructuring the plan detail page, or (c) rewriting the schema editor +internals. + +## Goals + +- Give the schema editor enough horizontal room to feel polished without + losing the plan context behind it. +- Fix the four BYT-9473 defects in one coherent change. +- Stay inside the existing design system (Sheet for editing, shared UI + components, semantic color tokens). + +## Non-Goals + +- No redesign of the schema editor's internal layout (tree / table editor / + DDL preview). The "diff-first / ER diagram / DDL split / step builder" + options were explored and ruled out. +- No changes to other call sites of `SchemaEditorLite`. Only + `SchemaEditorSheet` in plan detail gets the new container behavior. +- No persistence of the maximize toggle across sessions. Always resets to + the default width on each open. +- No new schema editor tests. The component has no integration tests today; + this change is not the place to add them. + +## Design + +### 1. Container behavior + +**Default open** — same as today: right-side `Sheet` at the `xlarge` width +tier (70rem). No regression for users with current habits. + +**New `huge` width tier** — extend `sheetContentVariants` in +`frontend/src/react/components/ui/sheet.tsx`: + +```ts +huge: "w-[95vw]", +``` + +This leaves a 5vw strip on the left as the visual anchor the user asked for. + +**Maximize toggle** — a `⤢` icon button (`Maximize2` / `Minimize2` from +`lucide-react`) in the sheet header, immediately left of the close X. Click +toggles between `xlarge` and `huge`. Tooltips: "Maximize" / "Restore" (new +locale entries). + +**State** — local `useState(false)` inside `SchemaEditorSheet`. The body is +already mounted via `{open && }`, so closing and +reopening the sheet remounts the body and resets `maximized` to `false`. No +localStorage, no Pinia. + +**Strip click semantics** — keep Base UI's default: clicking the scrim / +strip closes the sheet (same as every other sheet in the app). The strip is +purely a visual anchor; it is not a de-maximize handle. Toggle is via the +button only. This avoids hijacking Base UI's outside-click behavior and +keeps a single mental model for sheet dismissal across the app. + +**Keyboard** — `Esc` closes (existing behavior, unchanged). No new shortcut +for maximize, to avoid collision risk with the schema editor's own +keybindings. + +### 2. Sheet header actions slot + +The shared `SheetHeader` currently renders `children` (a flex column for +title + description) and a fixed `SheetClose`. There's no place to inject a +secondary action like the maximize toggle. + +Add an optional `actions` slot rendered immediately before the close button: + +```tsx +function SheetHeader({ className, children, actions, ...props }) { + return ( +
+
{children}
+ {actions ? ( +
{actions}
+ ) : null} + + + +
+ ); +} +``` + +This is a reusable extension. Any other sheet that needs a header-level +action (e.g. a settings gear, an external-link icon) can use the same slot. +No existing callers need to change. + +### 3. BYT-9473 polish fixes + +#### 3.1 Column "C" icon mismatch + +Today `AsideTree.tsx:472-477`: + +```tsx +case "column": + return
C
; +``` + +SQL Editor uses a proper SVG `ColumnIcon` in +`frontend/src/react/components/sql-editor/SchemaPane/TreeNode/icons.tsx:133-150`. + +**Fix** — create a shared module +`frontend/src/react/components/schema/icons.tsx` and move `ColumnIcon` +(plus its siblings if convenient) there. Import from both editors so they +cannot drift again. During the move, swap the raw `text-gray-500` for the +semantic `text-control-light`, per AGENTS.md "no raw color values". + +#### 3.2 "New table" action does not work + +`TableNameDialog` is a Base UI `Dialog` rendered inside an open `Sheet`. Both +portal into the same `overlay` layer family. The symptom in BYT-9473 is +consistent with the inner Dialog losing focus / event propagation to the +parent Sheet's focus trap — a known Base UI nested-modal hazard. + +**Fix** — replace the nested Dialog with an inline name-entry popover +anchored to the schema's "New table" menu item. Specifically: + +- Trigger from the tree context menu opens a `Popover` (Base UI) anchored to + the menu item, containing the same `Input` + Cancel/Create buttons. +- No portal nesting inside the overlay family, no focus trap conflict. +- Rename flow can keep the inline popover as well, or stay as a Dialog if + it is invoked from a context where no Sheet is open. Default: convert + both to the popover for consistency. + +This is meaningfully lighter than repairing the nested focus trap and +matches recent React UI patterns elsewhere in the app. + +#### 3.3 No highlight on "Add column" + +Today `TableEditor.tsx:69-92` pushes the new `ColumnMetadata` and marks it +`created`. The row's status text turns green but the row blends in; no +animation, no scroll, no focus shift. + +**Fix** — three small additions in `handleAddColumn`: + +1. Queue a scroll-to via the existing + `scrollStatus.queuePendingScrollToColumn(...)` (already used on click). +2. Set `data-just-added="true"` on the new row. A Tailwind keyframe + (`animate-row-flash`) fades a `bg-success/10` background over 1.2 s and + removes the attribute on animation end. +3. Focus the Name input on the new row so the user can immediately type. + +Define `animate-row-flash` in `frontend/src/assets/css/tailwind.css` via +`@keyframes` + `@utility`. Uses the semantic `--color-success` token. + +#### 3.4 General polish + +Tightly scoped — only changes that read as "unfinished" today: + +- Replace `
C
`-style bare text status indicators with shared + `Badge` for create / update / drop markers in the tree. Status text-color + alone (`text-success` / `text-warning` / `text-error`) is too quiet. +- Toolbar density pass on the editor body: current `gap-y-3` is uneven + against the database combobox. Tighten to `gap-y-2` and align baselines. +- Empty-state visual when the selected database has no tables yet (today + the panel is silent and looks broken). + +### 4. Files touched + +``` +frontend/src/react/components/ui/sheet.tsx + + add "huge" width tier (w-[95vw]) + + add optional `actions` slot to SheetHeader + +frontend/src/react/pages/project/plan-detail/components/SchemaEditorSheet.tsx + + maximized state + ⤢ Maximize2/Minimize2 toggle wired into SheetHeader actions + + width = maximized ? "huge" : "xlarge" + + new locale keys for "Maximize" / "Restore" tooltips + +frontend/src/react/components/schema/icons.tsx (new) + + house ColumnIcon (+ siblings if appropriate); text-control-light not text-gray-500 + +frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx + + replace
C
with the shared ColumnIcon + + wrap status text with Badge for create / update / drop markers + +frontend/src/react/components/SchemaEditorLite/Modals/TableNameDialog.tsx + → convert to an inline Popover-based component (rename file accordingly, + e.g. TableNamePopover.tsx); update call sites + +frontend/src/react/components/SchemaEditorLite/Panels/TableEditor.tsx + + handleAddColumn: queue scroll, set data-just-added, focus Name input + +frontend/src/assets/css/tailwind.css + + @keyframes + @utility for animate-row-flash (1.2s bg-success/10 fade) + +frontend/src/locales/*.json + + Maximize, Restore tooltip strings +``` + +### 5. Testing + +**Manual** — open a plan, click "Schema editor": + +- Default opens at `xlarge` width (no regression). +- ⤢ toggles to ~95vw; ⤢ again restores; closing and reopening always lands + back at `xlarge`. +- 5vw strip click closes the sheet (same as today's scrim behavior). +- Tree column rows show the new `ColumnIcon`, visually identical to SQL + Editor. +- "New table" creates a table, opens a tab, scrolls to it; works from + every entry point. +- "Add column" — new row scrolls into view, flashes briefly, Name input is + focused. +- `Esc` closes the sheet from both `xlarge` and `huge` modes. + +**Automated** — none required for this scope. Existing checks cover: + +- `pnpm --dir frontend type-check` +- `pnpm --dir frontend check` (lint, biome, layering scanner) +- `pnpm --dir frontend test` (existing tests must still pass) + +**i18n** — confirm no hardcoded display strings; the two new tooltip +strings are added to `frontend/src/locales/`. + +## Trade-offs Considered + +- **Full-page route** instead of a maximizable drawer — explicitly rejected. + Loses plan context; routing overhead is wrong for a short-lived + compose-then-insert flow. +- **Centered modal Dialog** — explicitly rejected. Violates AGENTS.md + ("Sheet for editing, Dialog for confirmations"). +- **Inline expansion** inside the plan detail statement section — explicitly + rejected. Crowds vertical space and competes with the SQL textarea. +- **Split pane** in plan detail — explicitly rejected. Restructures the plan + detail page for one feature; cost > benefit. +- **Persisting maximize across sessions** — rejected. Reset on each open + keeps state model simple; users who always want maximized can ask later. + +## Open Questions + +None at design time. Implementation may surface a focus-trap edge case in +section 3.2 (inline popover inside a sheet) — the fallback is to leave +rename as a Dialog and convert only the create path. diff --git a/frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx b/frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx index 309330f89595ec..8f352e492f440e 100644 --- a/frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx +++ b/frontend/src/react/components/SchemaEditorLite/Aside/AsideTree.tsx @@ -7,13 +7,24 @@ import { FileCode, FunctionSquare, Layers, + Plus, Table2, View, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useMemo, useRef, useState } from "react"; import type { NodeRendererProps } from "react-arborist"; import { Tree } from "react-arborist"; import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { ColumnIcon } from "@/react/components/schema/icons"; +import { Badge } from "@/react/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/react/components/ui/dropdown-menu"; import { getLayerRoot, LAYER_SURFACE_CLASS } from "@/react/components/ui/layer"; import { SearchInput } from "@/react/components/ui/search-input"; import { cn } from "@/react/lib/utils"; @@ -27,9 +38,16 @@ import { ProcedureMetadataSchema, ViewMetadataSchema, } from "@/types/proto-es/v1/database_service_pb"; +import { getDatabaseEngine } from "@/utils"; import { useSchemaEditorContext } from "../context"; +import { + engineSupportsEditFunctions, + engineSupportsEditProcedures, + engineSupportsEditViews, + engineSupportsMultiSchema, +} from "../core/spec"; import { SchemaNameDialog } from "../Modals/SchemaNameDialog"; -import { TableNameDialog } from "../Modals/TableNameDialog"; +import { TableNamePopover } from "../Modals/TableNamePopover"; import type { EditStatus } from "../types"; import { NodeCheckbox } from "./NodeCheckbox"; import type { TreeNode, TreeNodeForTable } from "./tree-builder"; @@ -37,6 +55,7 @@ import { buildTree } from "./tree-builder"; import { useContextMenu } from "./useContextMenu"; export function AsideTree() { + const { t } = useTranslation(); const context = useSchemaEditorContext(); const { targets, @@ -51,6 +70,10 @@ export function AsideTree() { const [searchPattern, setSearchPattern] = useState(""); const containerRef = useRef(null); + // Anchor for the "+" dropdown — the same ref is used as the popover anchor + // when the user picks "New table" from the dropdown, so the popover always + // appears in the same spot regardless of how the user opened it. + const createTriggerRef = useRef(null); // Tree data — treeBuildVersion busts the cache after in-place metadata mutations const { tree, nodeMap } = useMemo( @@ -79,6 +102,7 @@ export function AsideTree() { typeof useSchemaEditorContext >["targets"][0]["metadata"]["schemas"][0]; table?: TreeNodeForTable["metadata"]["table"]; + anchorPoint: { x: number; y: number }; } | null>(null); const [schemaNameModalCtx, setSchemaNameModalCtx] = useState<{ @@ -109,6 +133,7 @@ export function AsideTree() { db: node.db, database: node.metadata.database, schema: node.metadata.schema, + anchorPoint: { x: menuState.x, y: menuState.y }, }); } else if (key === "rename-table" && node.type === "table") { setTableNameModalCtx({ @@ -116,6 +141,7 @@ export function AsideTree() { database: node.metadata.database, schema: node.metadata.schema, table: node.metadata.table, + anchorPoint: { x: menuState.x, y: menuState.y }, }); } else if (key === "drop-table" && node.type === "table") { const status = editStatus.getTableStatus(node.db, node.metadata); @@ -257,7 +283,15 @@ export function AsideTree() { rebuildTree(false); } }, - [menuState.node, hideMenu, editStatus, rebuildTree, tabs] + [ + menuState.node, + menuState.x, + menuState.y, + hideMenu, + editStatus, + rebuildTree, + tabs, + ] ); // Node click handler @@ -338,14 +372,173 @@ export function AsideTree() { [editStatus] ); + // Discoverable "+" dropdown next to the search input. Mirrors the + // right-click context menu but is always visible. Routes to the first + // target + first schema (the common single-DB single-schema case in + // plan-detail); power users with multi-schema setups can still + // right-click a specific schema. + const createActions = useMemo(() => { + if (readonly || targets.length === 0) return []; + const target = targets[0]; + const engine = getDatabaseEngine(target.database); + const firstSchema = target.metadata.schemas[0]; + if (!firstSchema) return []; + + const actions: { + key: string; + label: string; + onSelect: () => void; + separatorBefore?: boolean; + }[] = []; + + actions.push({ + key: "create-table", + label: t("schema-editor.actions.create-table"), + onSelect: () => { + const rect = createTriggerRef.current?.getBoundingClientRect(); + setTableNameModalCtx({ + db: target.database, + database: target.metadata, + schema: firstSchema, + anchorPoint: rect ? { x: rect.left, y: rect.bottom } : { x: 0, y: 0 }, + }); + }, + }); + + if (engineSupportsEditViews(engine)) { + actions.push({ + key: "create-view", + label: t("schema-editor.actions.create-view"), + onSelect: () => { + const view = create(ViewMetadataSchema, { + name: "new_view", + definition: "", + }) as ViewMetadata; + firstSchema.views.push(view); + editStatus.markEditStatus( + target.database, + { schema: firstSchema, view }, + "created" + ); + tabs.addTab({ + type: "view", + database: target.database, + metadata: { + database: target.metadata, + schema: firstSchema, + view, + }, + }); + rebuildTree(false); + }, + }); + } + + if (engineSupportsEditProcedures(engine)) { + actions.push({ + key: "create-procedure", + label: t("schema-editor.actions.create-procedure"), + onSelect: () => { + const procedure = create(ProcedureMetadataSchema, { + name: "new_procedure", + definition: "", + }) as ProcedureMetadata; + firstSchema.procedures.push(procedure); + editStatus.markEditStatus( + target.database, + { schema: firstSchema, procedure }, + "created" + ); + tabs.addTab({ + type: "procedure", + database: target.database, + metadata: { + database: target.metadata, + schema: firstSchema, + procedure, + }, + }); + rebuildTree(false); + }, + }); + } + + if (engineSupportsEditFunctions(engine)) { + actions.push({ + key: "create-function", + label: t("schema-editor.actions.create-function"), + onSelect: () => { + const func = create(FunctionMetadataSchema, { + name: "new_function", + definition: "", + }) as FunctionMetadata; + firstSchema.functions.push(func); + editStatus.markEditStatus( + target.database, + { schema: firstSchema, function: func }, + "created" + ); + tabs.addTab({ + type: "function", + database: target.database, + metadata: { + database: target.metadata, + schema: firstSchema, + function: func, + }, + }); + rebuildTree(false); + }, + }); + } + + if (engineSupportsMultiSchema(engine)) { + actions.push({ + key: "create-schema", + separatorBefore: true, + label: t("schema-editor.actions.create-schema"), + onSelect: () => { + setSchemaNameModalCtx({ + db: target.database, + database: target.metadata, + }); + }, + }); + } + + return actions; + }, [readonly, targets, t, editStatus, tabs, rebuildTree]); + return (
-
+
handleSearchChange(e.target.value)} - className="h-7" + className="h-7 flex-1" /> + {createActions.length > 0 && ( + + + + + + {createActions.map((action) => ( + + {action.separatorBefore && } + + {action.label} + + + ))} + + + )}
e.stopPropagation()} > @@ -408,9 +601,10 @@ export function AsideTree() { {/* Modals */} {tableNameModalCtx && ( - setTableNameModalCtx(null)} + anchorPoint={tableNameModalCtx.anchorPoint} db={tableNameModalCtx.db} database={tableNameModalCtx.database} schema={tableNameModalCtx.schema} @@ -470,11 +664,7 @@ function NodeIcon({ node }: { node: TreeNode }) { case "table": return ; case "column": - return ( -
- C -
- ); + return ; case "view": return ; case "procedure": @@ -511,6 +701,25 @@ function statusClassName(status: EditStatus): string { } } +// Single-character badge next to created/updated/dropped tree entries. +// Text-color alone was too quiet (BYT-9473); the badge is additive and +// keeps the existing color/strike-through on the label. +function StatusBadge({ status }: { status: EditStatus }) { + if (status === "normal") return null; + const variant = + status === "created" + ? "success" + : status === "updated" + ? "warning" + : "destructive"; + const letter = status === "created" ? "+" : status === "updated" ? "~" : "−"; + return ( + + {letter} + + ); +} + // Custom node renderer function NodeRenderer( props: NodeRendererProps & { @@ -577,6 +786,7 @@ function NodeRenderer( {treeNode.label || "(empty)"} +
); } diff --git a/frontend/src/react/components/SchemaEditorLite/EditorPanel.tsx b/frontend/src/react/components/SchemaEditorLite/EditorPanel.tsx index 17c07574479a01..4f926d14f5675b 100644 --- a/frontend/src/react/components/SchemaEditorLite/EditorPanel.tsx +++ b/frontend/src/react/components/SchemaEditorLite/EditorPanel.tsx @@ -1,4 +1,5 @@ import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useSchemaEditorContext } from "./context"; import { DatabaseEditor } from "./Panels/DatabaseEditor"; import { FunctionEditor } from "./Panels/FunctionEditor"; @@ -8,6 +9,7 @@ import { ViewEditor } from "./Panels/ViewEditor"; import { TabsContainer } from "./TabsContainer"; export function EditorPanel() { + const { t } = useTranslation(); const { tabs } = useSchemaEditorContext(); const { currentTab } = tabs; @@ -38,7 +40,7 @@ export function EditorPanel() {
{!currentTab && (
- Select a database object from the tree to edit + {t("schema-editor.select-object-hint")}
)} {currentTab?.type === "database" && ( diff --git a/frontend/src/react/components/SchemaEditorLite/Modals/TableNameDialog.tsx b/frontend/src/react/components/SchemaEditorLite/Modals/TableNamePopover.tsx similarity index 68% rename from frontend/src/react/components/SchemaEditorLite/Modals/TableNameDialog.tsx rename to frontend/src/react/components/SchemaEditorLite/Modals/TableNamePopover.tsx index c51dc8b0cb732d..374c345e3eb0d7 100644 --- a/frontend/src/react/components/SchemaEditorLite/Modals/TableNameDialog.tsx +++ b/frontend/src/react/components/SchemaEditorLite/Modals/TableNamePopover.tsx @@ -2,12 +2,8 @@ import { create } from "@bufbuild/protobuf"; import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/react/components/ui/button"; -import { - Dialog, - DialogContent, - DialogTitle, -} from "@/react/components/ui/dialog"; import { Input } from "@/react/components/ui/input"; +import { Popover, PopoverContent } from "@/react/components/ui/popover"; import { Engine } from "@/types/proto-es/v1/common_pb"; import type { Database, @@ -27,6 +23,10 @@ import { markUUID } from "../Panels/common"; interface Props { open: boolean; onClose: () => void; + // Screen-space coordinates of the click that triggered the popover. We + // anchor to a virtual 0×0 rect at this point so we don't depend on a DOM + // element that may have unmounted (e.g. the closed context menu item). + anchorPoint: { x: number; y: number }; db: Database; database: DatabaseMetadata; schema: SchemaMetadata; @@ -35,9 +35,10 @@ interface Props { const TABLE_NAME_REGEX = /^\S[\S ]*\S?$/; -export function TableNameDialog({ +export function TableNamePopover({ open, onClose, + anchorPoint, db, database, schema, @@ -53,7 +54,7 @@ export function TableNameDialog({ const isDuplicate = tableName !== table?.name && - schema.tables.some((t) => t.name === tableName); + schema.tables.some((tt) => tt.name === tableName); const isValid = tableName.length > 0 && TABLE_NAME_REGEX.test(tableName) && !isDuplicate; @@ -124,15 +125,40 @@ export function TableNameDialog({ onClose, ]); + // Virtual anchor at the click point. Base UI's Positioner accepts an + // object exposing getBoundingClientRect(); a 0×0 rect at (x, y) lets the + // popover float right next to where the user clicked. + const anchor = { + getBoundingClientRect: () => + ({ + width: 0, + height: 0, + x: anchorPoint.x, + y: anchorPoint.y, + top: anchorPoint.y, + left: anchorPoint.x, + right: anchorPoint.x, + bottom: anchorPoint.y, + toJSON() { + return this; + }, + }) as DOMRect, + }; + return ( - !next && onClose()}> - - - {isCreateMode - ? t("schema-editor.actions.create-table") - : t("schema-editor.actions.rename-table")} - -
+ !next && onClose()}> + +
+
+ {isCreateMode + ? t("schema-editor.actions.create-table") + : t("schema-editor.actions.rename-table")} +
setTableName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleConfirm(); + if (e.key === "Escape") onClose(); }} /> {isDuplicate && ( @@ -148,15 +175,15 @@ export function TableNameDialog({

)}
- -
- -
+ + ); } diff --git a/frontend/src/react/components/SchemaEditorLite/Panels/IndexesEditor/IndexesEditor.tsx b/frontend/src/react/components/SchemaEditorLite/Panels/IndexesEditor/IndexesEditor.tsx index c69df69dadd77b..d47b08e9938df9 100644 --- a/frontend/src/react/components/SchemaEditorLite/Panels/IndexesEditor/IndexesEditor.tsx +++ b/frontend/src/react/components/SchemaEditorLite/Panels/IndexesEditor/IndexesEditor.tsx @@ -1,5 +1,4 @@ -import { create } from "@bufbuild/protobuf"; -import { Plus, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/react/components/ui/button"; @@ -21,7 +20,6 @@ import type { SchemaMetadata, TableMetadata, } from "@/types/proto-es/v1/database_service_pb"; -import { IndexMetadataSchema } from "@/types/proto-es/v1/database_service_pb"; import { useSchemaEditorContext } from "../../context"; interface Props { @@ -47,18 +45,6 @@ export function IndexesEditor({ value: col.name, })); - const handleAddIndex = useCallback(() => { - const index = create(IndexMetadataSchema, { - name: `idx_${table.name}_${Date.now()}`, - expressions: [], - primary: false, - unique: false, - comment: "", - }); - table.indexes.push(index); - editStatus.markEditStatus(db, { schema, table }, "updated"); - }, [table, editStatus, db, schema]); - const handleDropIndex = useCallback( (index: IndexMetadata) => { const idx = table.indexes.indexOf(index); @@ -74,14 +60,6 @@ export function IndexesEditor({ return (
- {!isReadonly && ( -
- -
- )}
diff --git a/frontend/src/react/components/SchemaEditorLite/Panels/PartitionsEditor/PartitionsEditor.tsx b/frontend/src/react/components/SchemaEditorLite/Panels/PartitionsEditor/PartitionsEditor.tsx index 85dd6d16b1bcbc..c034dfd54c74af 100644 --- a/frontend/src/react/components/SchemaEditorLite/Panels/PartitionsEditor/PartitionsEditor.tsx +++ b/frontend/src/react/components/SchemaEditorLite/Panels/PartitionsEditor/PartitionsEditor.tsx @@ -1,5 +1,4 @@ -import { create } from "@bufbuild/protobuf"; -import { Plus, RotateCcw, Trash2 } from "lucide-react"; +import { RotateCcw, Trash2 } from "lucide-react"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/react/components/ui/button"; @@ -20,10 +19,7 @@ import type { TableMetadata, TablePartitionMetadata, } from "@/types/proto-es/v1/database_service_pb"; -import { - TablePartitionMetadata_Type, - TablePartitionMetadataSchema, -} from "@/types/proto-es/v1/database_service_pb"; +import { TablePartitionMetadata_Type } from "@/types/proto-es/v1/database_service_pb"; import { useSchemaEditorContext } from "../../context"; interface Props { @@ -44,19 +40,6 @@ export function PartitionsEditor({ const { t } = useTranslation(); const { editStatus } = useSchemaEditorContext(); - const handleAddPartition = useCallback(() => { - const firstPartition = table.partitions[0]; - const partition = create(TablePartitionMetadataSchema, { - name: `p${table.partitions.length}`, - type: firstPartition?.type ?? TablePartitionMetadata_Type.RANGE, - expression: firstPartition?.expression ?? "", - value: "", - subpartitions: [], - }); - table.partitions.push(partition); - editStatus.markEditStatus(db, { schema, table }, "updated"); - }, [table, editStatus, db, schema]); - const handleDropPartition = useCallback( (partition: TablePartitionMetadata) => { const status = editStatus.getPartitionStatus(db, { @@ -98,14 +81,6 @@ export function PartitionsEditor({ return (
- {!isReadonly && ( -
- -
- )}
diff --git a/frontend/src/react/components/SchemaEditorLite/Panels/PreviewPane.tsx b/frontend/src/react/components/SchemaEditorLite/Panels/PreviewPane.tsx index 3d1d763c959dbc..a02cd931f11bb6 100644 --- a/frontend/src/react/components/SchemaEditorLite/Panels/PreviewPane.tsx +++ b/frontend/src/react/components/SchemaEditorLite/Panels/PreviewPane.tsx @@ -151,16 +151,18 @@ export function PreviewPane({ db, database, schema, table }: Props) { {expanded && (
{pending && ( -
+
)} {error ? ( -
{error}
+
+              {error}
+            
) : ( handleColumnNameChange(column, e.target.value) } @@ -283,7 +295,7 @@ export function TableColumnEditor({ value={column.comment} disabled={disabled} size="xs" - className="border-none bg-transparent shadow-none focus-visible:ring-1" + className="border-none bg-transparent shadow-none enabled:hover:bg-control-bg/60 focus-visible:ring-1" onChange={(e) => handleCommentChange(column, e.target.value) } @@ -322,7 +334,7 @@ export function TableColumnEditor({ - {showIndexes && ( - - )} - {showPartitions && ( - - )} -
- {!readonly && !disableChangeTable && mode === "COLUMNS" && ( - )}
diff --git a/frontend/src/react/components/SchemaEditorLite/SchemaEditorLite.tsx b/frontend/src/react/components/SchemaEditorLite/SchemaEditorLite.tsx index 1aa22217bb8403..9a67650b594262 100644 --- a/frontend/src/react/components/SchemaEditorLite/SchemaEditorLite.tsx +++ b/frontend/src/react/components/SchemaEditorLite/SchemaEditorLite.tsx @@ -175,7 +175,7 @@ export const SchemaEditorLite = forwardRef<
{combinedLoading && ( -
+
)} diff --git a/frontend/src/react/components/schema/icons.tsx b/frontend/src/react/components/schema/icons.tsx new file mode 100644 index 00000000000000..ce6ef18a8ebb66 --- /dev/null +++ b/frontend/src/react/components/schema/icons.tsx @@ -0,0 +1,28 @@ +import { cn } from "@/react/lib/utils"; + +interface IconProps { + className?: string; +} + +// Single-column variant of `lucide:columns-3` (the second internal gap line +// removed). Used in both Schema Editor and SQL Editor tree views so the two +// surfaces stay visually identical. +export function ColumnIcon({ className }: IconProps) { + return ( + + + + + ); +} diff --git a/frontend/src/react/components/sql-editor/SchemaPane/TreeNode/icons.tsx b/frontend/src/react/components/sql-editor/SchemaPane/TreeNode/icons.tsx index 4e6b9fff6f5bd1..0ce95d761fbfbd 100644 --- a/frontend/src/react/components/sql-editor/SchemaPane/TreeNode/icons.tsx +++ b/frontend/src/react/components/sql-editor/SchemaPane/TreeNode/icons.tsx @@ -126,26 +126,6 @@ export function CheckConstraintIcon({ className }: IconProps) { return ; } -/** - * Vue's ColumnIcon is a hand-edited "lucide:columns-3" with the second - * gap line removed (single column instead of two). Reproduce the SVG. - */ -export function ColumnIcon({ className }: IconProps) { - return ( - - - - - ); -} +// ColumnIcon is shared with SchemaEditorLite; both surfaces must render +// identical icons so users don't see drift across editors. +export { ColumnIcon } from "@/react/components/schema/icons"; diff --git a/frontend/src/react/components/ui/combobox.tsx b/frontend/src/react/components/ui/combobox.tsx index 03eac9262a346b..9f4a7ed3fa2e77 100644 --- a/frontend/src/react/components/ui/combobox.tsx +++ b/frontend/src/react/components/ui/combobox.tsx @@ -65,6 +65,8 @@ type ComboboxBaseProps = { className?: string; disabled?: boolean; clearable?: boolean; + /** Trigger size — matches the Input component's tier names. Defaults to `md`. */ + size?: "sm" | "md"; /** Render dropdown via portal (use when inside overflow:hidden containers like modals) */ portal?: boolean; }; @@ -98,6 +100,7 @@ export function Combobox(props: ComboboxProps) { className, disabled, clearable = true, + size = "md", portal, } = props; const multiple = props.multiple === true; @@ -424,7 +427,10 @@ export function Combobox(props: ComboboxProps) { // wrap below a too-narrow label on a narrow container, which // looks broken. Keep the trigger on a single line in that case // and let the label truncate inside `renderTrigger`. - "flex items-center gap-1 min-h-9 w-full rounded-xs border border-control-border bg-background px-3 py-1 text-sm leading-5 cursor-pointer", + "flex items-center gap-1 w-full rounded-xs border border-control-border bg-background py-1 cursor-pointer", + size === "sm" + ? "min-h-7 px-2 text-xs leading-4" + : "min-h-9 px-3 text-sm leading-5", multiple && "flex-wrap", disabled && "opacity-50 cursor-not-allowed", open && "border-accent" diff --git a/frontend/src/react/components/ui/segmented-control.tsx b/frontend/src/react/components/ui/segmented-control.tsx index 76e05257d3c6a4..a287d7e193cbe8 100644 --- a/frontend/src/react/components/ui/segmented-control.tsx +++ b/frontend/src/react/components/ui/segmented-control.tsx @@ -18,6 +18,8 @@ interface SegmentedControlProps { ariaLabel: string; disabled?: boolean; className?: string; + /** Segment size — matches the Input/Combobox size tier names. Defaults to `md`. */ + size?: "sm" | "md"; } export function SegmentedControl({ @@ -27,7 +29,10 @@ export function SegmentedControl({ ariaLabel, disabled = false, className, + size = "md", }: SegmentedControlProps) { + const segmentSizeClasses = + size === "sm" ? "min-h-7 px-2 text-xs" : "min-h-8 px-3 text-sm"; return ( ({
diff --git a/frontend/src/react/pages/project/plan-detail/hooks/usePlanCheckActions.tsx b/frontend/src/react/pages/project/plan-detail/hooks/usePlanCheckActions.tsx new file mode 100644 index 00000000000000..58243c59e50d50 --- /dev/null +++ b/frontend/src/react/pages/project/plan-detail/hooks/usePlanCheckActions.tsx @@ -0,0 +1,90 @@ +import { create } from "@bufbuild/protobuf"; +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { planServiceClientConnect } from "@/connect"; +import { useVueState } from "@/react/hooks/useVueState"; +import { + projectNamePrefix, + pushNotification, + useCurrentUserV1, + useProjectV1Store, +} from "@/store"; +import { extractUserEmail } from "@/store/modules/v1/common"; +import { + GetPlanCheckRunRequestSchema, + GetPlanRequestSchema, + type PlanCheckRun, + RunPlanChecksRequestSchema, +} from "@/types/proto-es/v1/plan_service_pb"; +import { hasProjectPermissionV2 } from "@/utils"; +import { usePlanDetailContext } from "../context/PlanDetailContext"; + +// Wraps the run / refresh logic shared between PlanDetailChecks (per-spec +// section in the body) and the plan-level summary in PlanDetailMetadataSidebar. +// The running flag lives on the page context so both surfaces disable their +// Run buttons together — otherwise the user could trigger two concurrent +// runPlanChecks calls from the same page. +export function usePlanCheckActions() { + const { t } = useTranslation(); + const page = usePlanDetailContext(); + const { patchState, isRunningChecks, setIsRunningChecks } = page; + const projectStore = useProjectV1Store(); + const currentUser = useCurrentUserV1().value; + const projectName = `${projectNamePrefix}${page.projectId}`; + const project = useVueState(() => projectStore.getProjectByName(projectName)); + + const allowRunChecks = useMemo(() => { + if (page.plan.hasRollout) return false; + if (extractUserEmail(page.plan.creator) === currentUser.email) return true; + return hasProjectPermissionV2(project, "bb.planCheckRuns.run"); + }, [currentUser.email, page.plan.creator, page.plan.hasRollout, project]); + + const refreshChecks = useCallback(async (): Promise => { + const [nextPlan, runOrNull] = await Promise.all([ + planServiceClientConnect.getPlan( + create(GetPlanRequestSchema, { name: page.plan.name }) + ), + planServiceClientConnect + .getPlanCheckRun( + create(GetPlanCheckRunRequestSchema, { + name: `${page.plan.name}/planCheckRun`, + }) + ) + .catch(() => null), + ]); + const nextPlanCheckRuns = runOrNull ? [runOrNull] : []; + patchState({ plan: nextPlan, planCheckRuns: nextPlanCheckRuns }); + return nextPlanCheckRuns; + }, [page.plan.name, patchState]); + + const runChecks = useCallback(async () => { + try { + setIsRunningChecks(true); + await planServiceClientConnect.runPlanChecks( + create(RunPlanChecksRequestSchema, { name: page.plan.name }) + ); + await refreshChecks(); + pushNotification({ + module: "bytebase", + style: "SUCCESS", + title: t("plan.checks.started"), + }); + } catch (error) { + pushNotification({ + module: "bytebase", + style: "CRITICAL", + title: t("plan.checks.failed-to-run"), + description: String(error), + }); + } finally { + setIsRunningChecks(false); + } + }, [page.plan.name, refreshChecks, t]); + + return { + allowRunChecks, + isRunningChecks, + refreshChecks, + runChecks, + }; +} diff --git a/frontend/src/react/pages/project/plan-detail/hooks/usePlanDetailPage.ts b/frontend/src/react/pages/project/plan-detail/hooks/usePlanDetailPage.ts index 153b28d7bc748a..0e6ed183c7c33e 100644 --- a/frontend/src/react/pages/project/plan-detail/hooks/usePlanDetailPage.ts +++ b/frontend/src/react/pages/project/plan-detail/hooks/usePlanDetailPage.ts @@ -106,6 +106,8 @@ export interface PlanDetailPageSnapshot { export interface PlanDetailPageState extends PlanDetailPageSnapshot { isEditing: boolean; isRefreshing: boolean; + isRunningChecks: boolean; + setIsRunningChecks: (running: boolean) => void; lastRefreshTime: number; activePhases: Set; routeName?: string; @@ -318,6 +320,7 @@ export const usePlanDetailPage = ({ ); const [editingScopes, setEditingScopes] = useState>({}); const [isRefreshing, setIsRefreshing] = useState(false); + const [isRunningChecks, setIsRunningChecks] = useState(false); const [lastRefreshTime, setLastRefreshTime] = useState(0); const [pendingLeaveConfirm, setPendingLeaveConfirm] = useState(false); const latestSnapshotRef = useRef(snapshot); @@ -691,6 +694,8 @@ export const usePlanDetailPage = ({ ...snapshot, isEditing, isRefreshing, + isRunningChecks, + setIsRunningChecks, lastRefreshTime, activePhases, routeName, @@ -715,6 +720,7 @@ export const usePlanDetailPage = ({ closeTaskPanel, isEditing, isRefreshing, + isRunningChecks, lastRefreshTime, patchState, pendingLeaveConfirm, diff --git a/frontend/src/react/pages/project/plan-detail/utils/planCheck.ts b/frontend/src/react/pages/project/plan-detail/utils/planCheck.ts index 3a4170d75235af..14d0194dba93cd 100644 --- a/frontend/src/react/pages/project/plan-detail/utils/planCheck.ts +++ b/frontend/src/react/pages/project/plan-detail/utils/planCheck.ts @@ -28,6 +28,27 @@ export interface ResultGroup { results: PlanCheckRun_Result[]; } +export const getPlanCheckSummaryWithFallback = ( + planCheckRuns: PlanCheckRun[], + statusCount: Record | undefined +): PlanCheckSummary => { + if (planCheckRuns.length > 0) return getPlanCheckSummary(planCheckRuns); + const counts = statusCount ?? {}; + const running = counts[PlanCheckRun_Status[PlanCheckRun_Status.RUNNING]] || 0; + const success = counts[Advice_Level[Advice_Level.SUCCESS]] || 0; + const warning = counts[Advice_Level[Advice_Level.WARNING]] || 0; + const error = + (counts[Advice_Level[Advice_Level.ERROR]] || 0) + + (counts[PlanCheckRun_Status[PlanCheckRun_Status.FAILED]] || 0); + return { + error, + running, + success, + total: running + success + warning + error, + warning, + }; +}; + export const getPlanCheckSummary = ( planCheckRuns: PlanCheckRun[] ): PlanCheckSummary => { @@ -178,6 +199,22 @@ export const transformReleaseCheckResultsToPlanCheckRuns = ( const allResults: PlanCheckRun_Result[] = []; for (const result of results) { + if (result.advices.length === 0) { + // checkRelease succeeded for this target with no advices. Emit a + // synthetic SUCCESS result so the summary shows a Success badge — + // otherwise a clean run looks identical to "checks never ran". + allResults.push( + create(PlanCheckRun_ResultSchema, { + code: 0, + content: "", + status: Advice_Level.SUCCESS, + target: result.target, + title: "OK", + type: PlanCheckRun_Result_Type.STATEMENT_ADVISE, + }) + ); + continue; + } for (const advice of result.advices) { allResults.push( create(PlanCheckRun_ResultSchema, { From 43711e83cc245b7c74c6b085b925b4f624d817d0 Mon Sep 17 00:00:00 2001 From: ecmadao Date: Tue, 12 May 2026 14:44:03 +0800 Subject: [PATCH 011/127] fix: sql editor ux issues (#20303) * fix: sql editor issues * fix: sql editor issues * fix: sql editor ux detail * fix: sql editor ux issues * chore: update * chore: update * chore: update --- .../src/react/components/AdvancedSearch.tsx | 31 ++++++- .../SchemaDiagram/Navigator/Navigator.tsx | 63 +++++++++++-- .../components/header/HeaderBreadcrumb.tsx | 11 +-- .../components/sql-editor/AccessGrantItem.tsx | 17 +++- .../sql-editor/AccessGrantRequestDrawer.tsx | 2 +- .../components/sql-editor/AccessPane.tsx | 2 +- .../ConnectionPane/ConnectionPane.tsx | 83 +++++++++++------ .../sql-editor/ResultPanel/ResultPanel.tsx | 55 +++++++----- .../ResultView/SelectionCopyTooltips.tsx | 47 +++++++--- .../sql-editor/ResultView/TableCell.tsx | 2 +- .../sql-editor/SchemaPane/SchemaPane.tsx | 50 +++++------ .../SchemaPane/TreeNode/ColumnNode.tsx | 88 ++++++++++++------- .../components/sql-editor/SchemaPane/click.ts | 68 +++++++++----- .../react/components/sql-editor/SheetTree.tsx | 1 + .../components/sql-editor/WorksheetPane.tsx | 2 +- frontend/src/react/components/ui/checkbox.tsx | 12 ++- frontend/src/react/components/ui/tree.tsx | 36 +++++++- frontend/src/react/locales/en-US.json | 3 +- frontend/src/react/locales/es-ES.json | 3 +- frontend/src/react/locales/ja-JP.json | 3 +- frontend/src/react/locales/vi-VN.json | 3 +- frontend/src/react/locales/zh-CN.json | 1 + 22 files changed, 405 insertions(+), 178 deletions(-) diff --git a/frontend/src/react/components/AdvancedSearch.tsx b/frontend/src/react/components/AdvancedSearch.tsx index c78d3be0df08fc..ed8cdeca8a41b8 100644 --- a/frontend/src/react/components/AdvancedSearch.tsx +++ b/frontend/src/react/components/AdvancedSearch.tsx @@ -636,7 +636,18 @@ export function AdvancedSearch({ className="flex min-w-0 items-center h-9 overflow-hidden border border-control-border rounded-xs bg-background transition-colors" onClick={() => inputRef.current?.focus()} > -
+ {/* + * No hard `max-w-[60%]` here on purpose. The tags container is a + * flex item with `shrink` and the input sibling already declares + * `min-w-[120px] flex-1`, so flexbox guarantees the input keeps + * its minimum typing width and the tags only have to shrink (and + * scroll horizontally) when their natural size truly exceeds + * what's left. A 60% cap forced the tags to clip even when the + * bar had plenty of unused space on the right — visible in the + * AccessPane where two short scope tags read as "status: 开启 + * × stat…". + */} +
@@ -695,10 +706,24 @@ export function AdvancedSearch({
- {/* Input */} + {/* + * Input min-width is conditional on whether any tags are + * present: + * - No tags: reserve 120px so the placeholder text is fully + * legible. + * - Has tags: the placeholder is hidden anyway (see below), + * so we only need room for the cursor — 40px. Without this + * the previous fixed `min-w-[120px]` forced the tags + * container to shrink even when the bar had visible empty + * space, which is what made scope tags clip in panels like + * AccessPane. + */} 0 ? "min-w-[40px]" : "min-w-[120px]" + )} value={inputText} placeholder={visibleTags.length > 0 ? "" : placeholder} onClick={handleInputClick} diff --git a/frontend/src/react/components/SchemaDiagram/Navigator/Navigator.tsx b/frontend/src/react/components/SchemaDiagram/Navigator/Navigator.tsx index d9c1cbc3a2c2e2..c518e140656309 100644 --- a/frontend/src/react/components/SchemaDiagram/Navigator/Navigator.tsx +++ b/frontend/src/react/components/SchemaDiagram/Navigator/Navigator.tsx @@ -1,5 +1,5 @@ import { ChevronLeft } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { PanelSearchBox } from "@/react/components/sql-editor/Panels/common/PanelSearchBox"; import { cn } from "@/react/lib/utils"; import { getInstanceResource, hasSchemaProperty } from "@/utils"; @@ -8,16 +8,22 @@ import { SchemaSelector } from "./SchemaSelector"; import { NavigatorTree } from "./Tree"; interface NavigatorProps { - /** Override the tree's default height. */ + /** + * Optional explicit override for the virtualized tree height. Leave + * unset to let the tree fill the available vertical space inside the + * Navigator panel. + */ treeHeight?: number; } +const FALLBACK_TREE_HEIGHT = 480; + /** * React port of `Navigator/Navigator.vue`. Collapsible left sidebar * holding the schema selector (Postgres-style multi-schema only), a * search input, and the schema → table tree. */ -export function Navigator({ treeHeight = 480 }: NavigatorProps) { +export function Navigator({ treeHeight }: NavigatorProps) { const ctx = useSchemaDiagramContext(); const { databaseMetadata, @@ -34,6 +40,27 @@ export function Navigator({ treeHeight = 480 }: NavigatorProps) { [database] ); + // react-arborist needs a numeric height for virtualization. Measure + // the tree's flex container so the list fills the panel rather than + // sitting at a hardcoded 480px (which leaves empty space on tall + // viewports and clips on short ones). + const treeContainerRef = useRef(null); + const [measuredHeight, setMeasuredHeight] = useState(null); + useEffect(() => { + if (!expanded) return; + const el = treeContainerRef.current; + if (!el) return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) setMeasuredHeight(entry.contentRect.height); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [expanded]); + + const effectiveTreeHeight = + treeHeight ?? measuredHeight ?? FALLBACK_TREE_HEIGHT; + return (
-
- +
+
+ {/* + * The toggle is absolutely positioned and visually crosses the + * boundary between the Navigator and the Canvas. Two stacking + * pitfalls to keep in mind: + * - `Canvas` is the next flex sibling of `Navigator` inside a + * `SchemaDiagram` parent with `overflow-hidden`, so without + * `z-10` the half of the button that overhangs into Canvas + * gets painted UNDER Canvas (auto z-index + later-DOM-order + * wins for siblings in the same stacking context). The + * button effectively disappeared into the canvas. + * - When collapsed, Navigator's width is 0 so `-left-3` placed + * the button at x = -12..+12 relative to the parent's left + * edge — the left half got clipped by `overflow-hidden` and + * the right 12px was covered by Canvas, leaving the user no + * way to re-open. Anchoring the collapsed button at `left-1` + * keeps the whole 24px visible inside Canvas, on top. + */} diff --git a/frontend/src/react/components/header/HeaderBreadcrumb.tsx b/frontend/src/react/components/header/HeaderBreadcrumb.tsx index e6154a45154f27..6efb39bd6144e2 100644 --- a/frontend/src/react/components/header/HeaderBreadcrumb.tsx +++ b/frontend/src/react/components/header/HeaderBreadcrumb.tsx @@ -24,7 +24,6 @@ import { useNavigate, WORKSPACE_ROUTE_LANDING, } from "@/react/router"; -import { useAppStore } from "@/react/stores/app"; import { PlanType } from "@/types/proto-es/v1/subscription_service_pb"; import { ProjectCreateDialog } from "./ProjectCreateDialog"; import { ProjectSwitchPanel } from "./ProjectSwitchPanel"; @@ -63,7 +62,6 @@ function planVariant( // --------------------------------------------------------------------------- function WorkspaceSegment() { const { t } = useTranslation(); - const isSaaSMode = useAppStore((state) => state.isSaaSMode()); const workspace = useWorkspace(); const workspaceList = useWorkspaceList(); const currentWorkspaceName = workspace?.name ?? ""; @@ -73,12 +71,6 @@ function WorkspaceSegment() { const hasMultiple = workspaceList.length > 1; const switchWorkspace = useSwitchWorkspace(); const navigate = useNavigate(); - - // Self-host has a single workspace — no need to show the workspace segment. - if (!isSaaSMode) { - return null; - } - const [open, setOpen] = useState(false); const onSwitch = useCallback( @@ -229,11 +221,10 @@ function ProjectSegment({ showSeparator }: { showSeparator: boolean }) { // HeaderBreadcrumb — the assembled breadcrumb bar // --------------------------------------------------------------------------- export function HeaderBreadcrumb() { - const isSaaSMode = useAppStore((state) => state.isSaaSMode()); return (
- +
); } diff --git a/frontend/src/react/components/sql-editor/AccessGrantItem.tsx b/frontend/src/react/components/sql-editor/AccessGrantItem.tsx index 84ef2d83302585..6b0524bd12adf5 100644 --- a/frontend/src/react/components/sql-editor/AccessGrantItem.tsx +++ b/frontend/src/react/components/sql-editor/AccessGrantItem.tsx @@ -93,8 +93,19 @@ export function AccessGrantItem({ : "transition-colors duration-1000" )} > -
-
+ {/* + * `flex-wrap` + `justify-between` keeps the original "badges left, + * expiration right" layout when both fit on the row, but lets the + * row wrap when the panel is narrow. Pairing it with `shrink-0` + * on the badges container preserves each pill at its natural size + * so the label never wraps inside the pill (`脱敏豁免` → "脱敏豁 + * \n免"). When the expiration wraps to a second row it falls back + * to the row's start alignment (justify-between has no effect on + * a single-item row), so it reads naturally left-to-right under + * the badges instead of being stranded on the right. + */} +
+
{expirationText && ( - + {expirationText} )} diff --git a/frontend/src/react/components/sql-editor/AccessGrantRequestDrawer.tsx b/frontend/src/react/components/sql-editor/AccessGrantRequestDrawer.tsx index 4e3c78fbf1f0b8..965ceef415d33e 100644 --- a/frontend/src/react/components/sql-editor/AccessGrantRequestDrawer.tsx +++ b/frontend/src/react/components/sql-editor/AccessGrantRequestDrawer.tsx @@ -145,7 +145,7 @@ function AccessGrantRequestDrawerInner({ const durationOptions = useMemo( () => [ - { value: "1", label: t("sql-editor.duration-hours", { hours: 1 }) }, + { value: "1", label: t("sql-editor.duration-hour", { hours: 1 }) }, { value: "4", label: t("sql-editor.duration-hours", { hours: 4 }) }, { value: "24", label: t("sql-editor.duration-day", { days: 1 }) }, { value: "168", label: t("sql-editor.duration-days", { days: 7 }) }, diff --git a/frontend/src/react/components/sql-editor/AccessPane.tsx b/frontend/src/react/components/sql-editor/AccessPane.tsx index 34bf4e3195874c..51cbf47c434fff 100644 --- a/frontend/src/react/components/sql-editor/AccessPane.tsx +++ b/frontend/src/react/components/sql-editor/AccessPane.tsx @@ -290,7 +290,7 @@ export function AccessPane() { > {({ disabled }) => ( - ), - }} - /> + ); + } + return {token}; + })}

- {open && ( -
+
+ + + + + {currentWorkspace?.title} + + + + {workspaceList.map((ws) => ( - + ))} -
- )} + +
); } diff --git a/frontend/src/react/components/database/CreateDatabaseSheet.test.tsx b/frontend/src/react/components/database/CreateDatabaseSheet.test.tsx index e96df9961c09da..09287913f1c0b0 100644 --- a/frontend/src/react/components/database/CreateDatabaseSheet.test.tsx +++ b/frontend/src/react/components/database/CreateDatabaseSheet.test.tsx @@ -15,10 +15,12 @@ import { Engine } from "@/types/proto-es/v1/common_pb"; vi.mock("@/react/components/InstanceSelect", () => ({ InstanceSelect: (props: { onChange: (name: string, instance: unknown) => void; + portal?: boolean; value: string; }) => createElement("input", { "data-testid": "instance-select", + "data-portal": String(Boolean(props.portal)), value: props.value, onChange: (e: React.ChangeEvent) => props.onChange(e.target.value, undefined), @@ -64,6 +66,7 @@ vi.mock("@/react/components/ui/combobox", () => ({ value, onChange, placeholder, + portal, }: { value: string; onChange: (v: string) => void; @@ -71,10 +74,12 @@ vi.mock("@/react/components/ui/combobox", () => ({ noResultsText?: string; options?: unknown[]; onSearch?: (q: string) => void; + portal?: boolean; renderValue?: (opt: unknown) => ReactNode; }) => createElement("input", { "data-testid": "combobox", + "data-portal": String(Boolean(portal)), value, placeholder, onChange: (e: React.ChangeEvent) => @@ -223,6 +228,19 @@ async function renderSheet(enforceIssueTitle: boolean): Promise { }); } +async function renderSheetWithoutFixedProject(): Promise { + await act(async () => { + root.render( + createElement(CreateDatabaseSheet, { + open: true, + onClose: () => {}, + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); +} + async function fillInstance(): Promise { const input = container.querySelector( "[data-testid='instance-select']" @@ -261,6 +279,20 @@ async function flush(): Promise { } describe("CreateDatabaseSheet — enforceIssueTitle (BYT-9310)", () => { + it("portals project and instance dropdowns out of the scrollable sheet body", async () => { + await renderSheetWithoutFixedProject(); + + const projectSelect = container.querySelector( + "input[placeholder='common.project']" + ) as HTMLInputElement; + const instanceSelect = container.querySelector( + "[data-testid='instance-select']" + ) as HTMLInputElement; + + expect(projectSelect.dataset.portal).toBe("true"); + expect(instanceSelect.dataset.portal).toBe("true"); + }); + it("auto-fills title from database name when enforceIssueTitle is false", async () => { await renderSheet(false); await fillInstance(); diff --git a/frontend/src/react/components/database/CreateDatabaseSheet.tsx b/frontend/src/react/components/database/CreateDatabaseSheet.tsx index 3c3c0e7c7499cd..c0c9226a63c76a 100644 --- a/frontend/src/react/components/database/CreateDatabaseSheet.tsx +++ b/frontend/src/react/components/database/CreateDatabaseSheet.tsx @@ -329,6 +329,7 @@ export function CreateDatabaseSheet({ setProjectName(name)} + portal />
)} @@ -350,6 +351,7 @@ export function CreateDatabaseSheet({ value={instanceName} onChange={handleInstanceChange} engines={enginesSupportCreateDatabase()} + portal />
diff --git a/frontend/src/react/components/ui/sheet.test.tsx b/frontend/src/react/components/ui/sheet.test.tsx new file mode 100644 index 00000000000000..436550fc246a2d --- /dev/null +++ b/frontend/src/react/components/ui/sheet.test.tsx @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +describe("Sheet layering policy", () => { + test("does not keep stale raw z-index tokens in the shared sheet surface", () => { + const source = readFileSync( + join(process.cwd(), "src/react/components/ui/sheet.tsx"), + "utf8" + ); + + expect(source).not.toMatch(/\bz-50\b/); + }); +}); diff --git a/frontend/src/react/components/ui/sheet.tsx b/frontend/src/react/components/ui/sheet.tsx index cded2c47604363..4a8647ad2c748b 100644 --- a/frontend/src/react/components/ui/sheet.tsx +++ b/frontend/src/react/components/ui/sheet.tsx @@ -53,7 +53,7 @@ function SheetOverlay({ // legitimate size is needed so all consumers stay aligned. const sheetContentVariants = cva( cn( - "fixed inset-y-0 right-0 z-50 flex h-full flex-col bg-background shadow-lg", + "fixed inset-y-0 right-0 flex h-full flex-col bg-background shadow-lg", "max-w-[100vw] outline-hidden", "data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full", "transition-transform duration-200 ease-out" diff --git a/frontend/src/react/pages/project/ProjectIssueDetailPage.test.tsx b/frontend/src/react/pages/project/ProjectIssueDetailPage.test.tsx new file mode 100644 index 00000000000000..abdf20c9d19461 --- /dev/null +++ b/frontend/src/react/pages/project/ProjectIssueDetailPage.test.tsx @@ -0,0 +1,95 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + LAYER_ROOT_ID, + LAYER_SURFACE_CLASS, +} from "@/react/components/ui/layer"; +import { ProjectIssueDetailPage } from "./ProjectIssueDetailPage"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + page: { + ready: true, + sidebarMode: "MOBILE", + mobileSidebarOpen: true, + desktopSidebarWidth: 0, + setMobileSidebarOpen: vi.fn(), + setEditing: vi.fn(), + patchState: vi.fn(), + refreshState: vi.fn(), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/react/hooks/useVueState", () => ({ + useVueState: (getter: () => unknown) => getter(), +})); + +vi.mock("@/router", () => ({ + router: { + currentRoute: { value: { query: {} } }, + replace: vi.fn(), + }, +})); + +vi.mock("./issue-detail/hooks/useIssueDetailPage", () => ({ + useIssueDetailPage: () => mocks.page, +})); + +vi.mock("./issue-detail/components/IssueDetailActivity", () => ({ + IssueDetailActivity: () =>
, +})); + +vi.mock("./issue-detail/components/IssueDetailBranchContent", () => ({ + IssueDetailBranchContent: () =>
, +})); + +vi.mock("./issue-detail/components/IssueDetailHeader", () => ({ + IssueDetailHeader: () =>
, +})); + +vi.mock("./issue-detail/components/IssueDetailSidebar", () => ({ + IssueDetailSidebar: () =>
)} - {showMobileSidebar && ( -
- -
-
- + +
+
+ +
-
-
- )} +
, + getLayerRoot("overlay") + )}
); diff --git a/frontend/src/react/pages/project/database-detail/DatabaseExportSchemaButton.tsx b/frontend/src/react/pages/project/database-detail/DatabaseExportSchemaButton.tsx index ad0a04fb9d6f43..7e041b637168a9 100644 --- a/frontend/src/react/pages/project/database-detail/DatabaseExportSchemaButton.tsx +++ b/frontend/src/react/pages/project/database-detail/DatabaseExportSchemaButton.tsx @@ -1,12 +1,15 @@ import { create } from "@bufbuild/protobuf"; import { ConnectError } from "@connectrpc/connect"; import { ChevronDown, Download } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { databaseServiceClientConnect } from "@/connect"; -import { Button } from "@/react/components/ui/button"; -import { useClickOutside } from "@/react/hooks/useClickOutside"; -import { cn } from "@/react/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/react/components/ui/dropdown-menu"; import { pushNotification } from "@/store"; import type { Database } from "@/types/proto-es/v1/database_service_pb"; import { @@ -31,9 +34,6 @@ export function DatabaseExportSchemaButton({ const { t } = useTranslation(); const [exporting, setExporting] = useState(false); const [open, setOpen] = useState(false); - const containerRef = useRef(null); - - useClickOutside(containerRef, open, () => setOpen(false)); const options = useMemo( () => [ @@ -104,32 +104,25 @@ export function DatabaseExportSchemaButton({ ); return ( -
- - {open && !disabled && !exporting && ( -
- {options.map((option) => ( - - ))} -
- )} -
+ + + {options.map((option) => ( + void handleExport(option.key)} + > + {option.label} + + ))} + + ); } diff --git a/frontend/src/react/pages/project/issue-detail/components/IssueDetailDatabaseExportView.tsx b/frontend/src/react/pages/project/issue-detail/components/IssueDetailDatabaseExportView.tsx index 3995332dba8a11..ae9597c6357dd3 100644 --- a/frontend/src/react/pages/project/issue-detail/components/IssueDetailDatabaseExportView.tsx +++ b/frontend/src/react/pages/project/issue-detail/components/IssueDetailDatabaseExportView.tsx @@ -10,15 +10,20 @@ import { Pause, X, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { planServiceClientConnect } from "@/connect"; import { EngineIcon } from "@/react/components/EngineIcon"; import { Button } from "@/react/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/react/components/ui/dropdown-menu"; import { Input } from "@/react/components/ui/input"; import { Switch } from "@/react/components/ui/switch"; import { Tooltip } from "@/react/components/ui/tooltip"; -import { useClickOutside } from "@/react/hooks/useClickOutside"; import { useVueState } from "@/react/hooks/useVueState"; import { cn } from "@/react/lib/utils"; import { router } from "@/router"; @@ -433,13 +438,9 @@ function IssueDetailDatabaseExportTasks({ function IssueDetailDatabaseExportTaskActions({ task }: { task: Task }) { const { t } = useTranslation(); const page = useIssueDetailContext(); - const [menuOpen, setMenuOpen] = useState(false); const [pendingAction, setPendingAction] = useState< "RUN" | "SKIP" | "CANCEL" | undefined >(); - const menuRef = useRef(null); - - useClickOutside(menuRef, menuOpen, () => setMenuOpen(false)); const stage = useMemo(() => { return page.rollout?.stages.find((candidate) => @@ -462,7 +463,7 @@ function IssueDetailDatabaseExportTaskActions({ task }: { task: Task }) { return ( <> -
+
{primaryAction && ( )} {(canSkip || canCancel) && ( - <> - - {menuOpen && ( -
- {canSkip && ( - - )} - {canCancel && ( - - )} -
- )} - + + + {canSkip && ( + setPendingAction("SKIP")}> + {t("common.skip")} + + )} + {canCancel && ( + setPendingAction("CANCEL")}> + {t("common.cancel")} + + )} + + )}
diff --git a/frontend/src/react/pages/settings/SemanticTypesPage.tsx b/frontend/src/react/pages/settings/SemanticTypesPage.tsx index 843867dab62260..bf5cc219dfe50f 100644 --- a/frontend/src/react/pages/settings/SemanticTypesPage.tsx +++ b/frontend/src/react/pages/settings/SemanticTypesPage.tsx @@ -7,6 +7,11 @@ import { FeatureAttention } from "@/react/components/FeatureAttention"; import { Button } from "@/react/components/ui/button"; import { Input } from "@/react/components/ui/input"; import { NumberInput } from "@/react/components/ui/number-input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/react/components/ui/popover"; import { Sheet, SheetBody, @@ -92,28 +97,6 @@ function useEscapeKey(onEscape: () => void) { }, [onEscape]); } -function useClickOutside( - ref: React.RefObject, - onClickOutside: () => void -) { - useEffect(() => { - // Delay by one frame so the click that opened the popover doesn't - // immediately trigger the outside-click handler. - const id = requestAnimationFrame(() => { - document.addEventListener("mousedown", handler); - }); - function handler(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - onClickOutside(); - } - } - return () => { - cancelAnimationFrame(id); - document.removeEventListener("mousedown", handler); - }; - }, [ref, onClickOutside]); -} - export function SemanticTypesPage() { const { t } = useTranslation(); const settingStore = useSettingV1Store(); @@ -1290,14 +1273,13 @@ function IconPicker({ value, onChange }: IconPickerProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [tempValue, setTempValue] = useState(value); - const popoverRef = useRef(null); const fileInputRef = useRef(null); - const dismiss = useCallback(() => setOpen(false), []); - useClickOutside(popoverRef, dismiss); - const handleOpen = () => { - setTempValue(value); - setOpen(true); + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + setTempValue(value); + } + setOpen(nextOpen); }; const handleFileSelect = (e: React.ChangeEvent) => { @@ -1312,73 +1294,67 @@ function IconPicker({ value, onChange }: IconPickerProps) { }; return ( -
- {value ? ( -
- - -
- ) : ( - - )} - {open && ( -
-
fileInputRef.current?.click()} - > - {tempValue ? ( -
- ) : ( - - {t("common.upload")} - - )} - + +
fileInputRef.current?.click()} + > + {tempValue ? ( +
-
-
- - - -
+ ) : ( + + {t("common.upload")} + + )} +
- )} -
+
+ + + +
+ + ); } @@ -1398,42 +1374,34 @@ function DeleteConfirmButton({ onConfirm, }: DeleteConfirmButtonProps) { const { t } = useTranslation(); - const popoverRef = useRef(null); - const dismiss = useCallback(() => onShowChange(false), [onShowChange]); - useClickOutside(popoverRef, dismiss); return ( -
- - {show && ( -
-

{message}

-
- - -
+ + +

{message}

+
+ +
- )} -
+ + ); } diff --git a/frontend/src/react/pages/settings/profile/ProfilePage.tsx b/frontend/src/react/pages/settings/profile/ProfilePage.tsx index 8c07dddb8080c9..a7ad4afe8b6d50 100644 --- a/frontend/src/react/pages/settings/profile/ProfilePage.tsx +++ b/frontend/src/react/pages/settings/profile/ProfilePage.tsx @@ -15,9 +15,14 @@ import { DialogDescription, DialogTitle, } from "@/react/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/react/components/ui/dropdown-menu"; import { FeatureModal } from "@/react/components/ui/feature-modal"; import { Input } from "@/react/components/ui/input"; -import { useClickOutside } from "@/react/hooks/useClickOutside"; import { useVueState } from "@/react/hooks/useVueState"; import { RegenerateRecoveryCodesView } from "@/react/pages/settings/two-factor/RegenerateRecoveryCodesView"; import { router } from "@/router"; @@ -138,13 +143,8 @@ export function ProfilePage({ principalEmail }: ProfilePageProps) { const [showFeatureModal, setShowFeatureModal] = useState(false); const [showDisable2FAConfirm, setShowDisable2FAConfirm] = useState(false); const [showRegenerateView, setShowRegenerateView] = useState(false); - const [showEllipsisMenu, setShowEllipsisMenu] = useState(false); const editNameRef = useRef(null); - const ellipsisMenuRef = useRef(null); - useClickOutside(ellipsisMenuRef, showEllipsisMenu, () => - setShowEllipsisMenu(false) - ); // --- Password validity --- const passwordErrors = useMemo(() => { @@ -603,29 +603,22 @@ export function ProfilePage({ principalEmail }: ProfilePageProps) { {t("two-factor.recovery-codes.self")} {!showRegenerateView && ( -
- - {showEllipsisMenu && ( -
- -
- )} -
+ + + setShowRegenerateView(true)} + > + {t("common.regenerate")} + + + )}

From 4c77abd1c0a15807f3827f3aa98083ac068c403b Mon Sep 17 00:00:00 2001 From: Vincent Huang <40749774+vsai12@users.noreply.github.com> Date: Tue, 12 May 2026 01:25:27 -0700 Subject: [PATCH 018/127] =?UTF-8?q?refactor(advisor/tidb):=20migrate=20typ?= =?UTF-8?q?e-byte=20pair=20to=20omni=20AST=20+=20add=20cumulative=20#22=20?= =?UTF-8?q?(Phase=201.5=20=C2=A71.5.3=20batch=2013)=20(#20312)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(advisor/tidb): migrate type-byte pair to omni AST + add cumulative #22 (Phase 1.5 §1.5.3 batch 13) Two advisors with step-2a-flagged type-byte dispatches, validating the three-axis discipline from batch 11 on first real use: - advisor_column_current_time_count_limit (DATETIME/TIMESTAMP) - advisor_column_maximum_character_length (TypeString CHAR/BINARY) 39 of 51 advisors merged once this lands (37 → 39). 12 remaining + 1 Class III blocked. ## column_current_time_count_limit Recipe A with cross-statement state accumulator. Pingcap dispatched on `mysql.TypeDatetime, mysql.TypeTimestamp` — distinct type bytes, no unification. Omni surfaces both as their own DataType.Name ("DATETIME"/"TIMESTAMP"), so the type-name predicate is a straight mapping via the new omniIsTimeType helper. CURRENT_TIMESTAMP detection: pingcap walked column.Options[] looking for ColumnOptionDefaultValue/OnUpdate with *ast.FuncCallExpr whose FnName.L matched the synonyms. Omni surfaces these as separate top-level fields on ColumnDef: - col.DefaultValue ExprNode → DEFAULT expression - col.OnUpdate ExprNode → ON UPDATE expression Both fields can carry a *omniast.FuncCallExpr with Name (case- preserved from user input). New omniIsDefaultCurrentTime / omniIsOnUpdateCurrentTime / omniIsCurrentTimeFuncCall helpers (in utils.go) preserve pingcap's case-insensitive synonym matching via strings.ToUpper. Line attribution: preserved pingcap's statement-line behavior (`ostmt.AbsoluteLine(n.Loc.Start)`). Mysql analog diverges — uses column-line (`r.LocToLine(col.Loc)`) — but the advice content is table-level ("Table t has N CURRENT_TIMESTAMP columns") so statement-line is the natural choice. Preserve pingcap. ## column_maximum_character_length — cumulative #22 territory Step 2a flagged `mysql.TypeString` dispatch. Empirical cross-reference against the three-axis discipline identified TypeString as the same family-pair unification as TypeBlob (#18) and TypeTiny (#20): Pingcap mysql.TypeString covers: - CHAR (non-binary charset) - BINARY (binary charset) Omni splits to distinct DataType.Name: - CHAR → "CHAR" - BINARY → "BINARY" The pingcap-typed rule fired on BOTH (TypeString match was charset- agnostic). A mechanical port matching only "CHAR" would silently drop BINARY coverage. Mysql analog (`getCharLengthFromOmni` in utils_omni.go) only matches "CHAR" — preserves a pre-omni mysql ANTLR gap (CHAR_SYMBOL distinct from BINARY_SYMBOL); long-standing, not regression. No mysql ticket per invariant #10. Fix: - omniIsCharOrBinaryType (utils.go): matches both names. - omniCharLength (utils.go): returns length, defaulting to 1 for bare CHAR/BINARY (MySQL default; pingcap's GetFlen() returned this canonically). Returns 0 for non-CHAR/BINARY types (callers gate on the predicate first). Cardinality: pingcap-tidb broke after first match → ONE advice per top-level statement. Mysql analog emits per-column. Preserve pingcap via early-return-on-first-match in checkStmtForCharLength. Pinned with multi-column fixture (CHAR(225) + BINARY(225)) expecting 1 advice. ## Cumulative #22 added to plan doc Documents TypeString CHAR/BINARY unification with the empirical mysql-pre-omni-ANTLR-gap framing. Step 2a checklist updated to include TypeString alongside TypeBlob (#18) and TypeTiny (#20). Second consecutive step-2a-preempted batch (batch 10 caught TINYINT/ BOOLEAN, batch 13 caught CHAR/BINARY). The three-axis discipline from batch 11 is paying off — both bugs were the same family-pair unification class, both caught pre-merge via the same grep + cross- reference protocol. ## Pre-batch protocol receipts - [x] Both pingcap visitor bodies read side-by-side. Both return `(in, false)` from Enter → Recipe A. - [x] Coupling scan: 5 new helpers in utils.go (time + char/binary pair); no cross-file coupling between advisors. - [x] Step 2a: 2 type-byte hits flagged; cross-referenced; TypeDatetime/ TypeTimestamp are distinct (no unification concern), TypeString is unified per cumulative #22 (new entry added). - [x] Three-axis discipline applied: (a) Field-by-field DataType audit: covered Length, Name; CHAR length default verified via omniCharLength tests (b) Type-name alias canonicalization: TypeString → CHAR/BINARY split documented in cumulative #22 (c) Bare-form × modifier Cartesian product: N/A (this rule doesn't compare type strings against a catalog, just reads Length) - [x] Shape audit against mysql analogs. Mysql diverges on: - line attribution (column-line vs statement-line) — preserved pingcap - cardinality (per-col vs single-advice-per-stmt) — preserved pingcap - BINARY coverage (CHAR-only in mysql) — restored full pingcap-tidb behavior - [x] Unit tests written in commit-1 (batch 11 discipline): - TestOmniIsTimeType (8 cases) - TestOmniIsCurrentTimeFuncCall (8 cases — synonyms + negatives) - TestOmniIsCharOrBinaryType (8 cases — CHAR/BINARY positive + VARCHAR/VARBINARY/etc. negative) - TestOmniCharLength (8 cases — default-1, explicit lengths, non-CHAR/BINARY) - [x] Fixture pins for BINARY coverage + cardinality preservation. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(advisor/tidb): include actual length in COLUMN_MAXIMUM_CHARACTER_LENGTH advice (batch 13 follow-up) Previously the advice only mentioned the maximum ("...is bigger than 20..."); now it includes the offending column's actual length ("...is 225, bigger than 20..."). UX improvement for users diagnosing CHAR/BINARY length violations. Also clears SonarCloud godre:S8193 ("unnecessary variable declaration") on the three `if charLength := omniCharLength(...); charLength > maximum` sites — charLength is now consumed in the advice content, not just the predicate. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- ...advisor_column_current_time_count_limit.go | 238 +++++++----------- ...advisor_column_maximum_character_length.go | 152 ++++++----- .../test/column_maximum_character_length.yaml | 43 +++- backend/plugin/advisor/tidb/utils.go | 104 ++++++++ backend/plugin/advisor/tidb/utils_test.go | 111 ++++++++ 5 files changed, 422 insertions(+), 226 deletions(-) diff --git a/backend/plugin/advisor/tidb/advisor_column_current_time_count_limit.go b/backend/plugin/advisor/tidb/advisor_column_current_time_count_limit.go index 3883a04c15f0f1..cf6a342240a125 100644 --- a/backend/plugin/advisor/tidb/advisor_column_current_time_count_limit.go +++ b/backend/plugin/advisor/tidb/advisor_column_current_time_count_limit.go @@ -1,21 +1,16 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" "slices" - "strings" - - "github.com/bytebase/bytebase/backend/plugin/advisor/code" - "github.com/pingcap/tidb/pkg/parser/ast" - "github.com/pingcap/tidb/pkg/parser/mysql" + "github.com/bytebase/omni/tidb/ast" "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" + "github.com/bytebase/bytebase/backend/plugin/advisor/code" ) const ( @@ -25,7 +20,6 @@ const ( var ( _ advisor.Advisor = (*ColumnCurrentTimeCountLimitAdvisor)(nil) - _ ast.Visitor = (*columnCurrentTimeCountLimitChecker)(nil) ) func init() { @@ -38,8 +32,7 @@ type ColumnCurrentTimeCountLimitAdvisor struct { // Check checks for current time column count limit. func (*ColumnCurrentTimeCountLimitAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -48,167 +41,130 @@ func (*ColumnCurrentTimeCountLimitAdvisor) Check(_ context.Context, checkCtx adv if err != nil { return nil, err } - checker := &columnCurrentTimeCountLimitChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - tableSet: make(map[string]tableData), - } - - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + title := checkCtx.Rule.Type.String() + + // Cross-statement accumulator: tracks per-table counts of + // DEFAULT-CURRENT_TIMESTAMP and ON-UPDATE-CURRENT_TIMESTAMP + // columns across all statements in the review. Pingcap-typed + // predecessor used the same single-pass accumulation pattern. + tableSet := make(map[string]currentTimeTableData) + + for _, ostmt := range stmts { + switch n := ostmt.Node.(type) { + case *ast.CreateTableStmt: + if n.Table == nil { + continue + } + tableName := n.Table.Name + line := ostmt.AbsoluteLine(n.Loc.Start) + for _, column := range n.Columns { + countCurrentTimeColumn(tableSet, tableName, column, line) + } + case *ast.AlterTableStmt: + if n.Table == nil { + continue + } + tableName := n.Table.Name + line := ostmt.AbsoluteLine(n.Loc.Start) + for _, cmd := range n.Commands { + if cmd == nil { + continue + } + switch cmd.Type { + case ast.ATAddColumn: + for _, column := range addColumnTargets(cmd) { + countCurrentTimeColumn(tableSet, tableName, column, line) + } + case ast.ATChangeColumn, ast.ATModifyColumn: + if cmd.Column != nil { + countCurrentTimeColumn(tableSet, tableName, cmd.Column, line) + } + default: + } + } + default: + } } - return checker.generateAdvice(), nil + return generateCurrentTimeAdvice(tableSet, level, title), nil } -type columnCurrentTimeCountLimitChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int - tableSet map[string]tableData -} - -type tableData struct { +// currentTimeTableData accumulates per-table counts of columns +// declaring DEFAULT CURRENT_TIMESTAMP and/or ON UPDATE CURRENT_TIMESTAMP +// (or their synonyms NOW/LOCALTIME/LOCALTIMESTAMP). +type currentTimeTableData struct { tableName string defaultCurrentTimeCount int onUpdateCurrentTimeCount int line int } -func (checker *columnCurrentTimeCountLimitChecker) generateAdvice() []*storepb.Advice { - var tableList []tableData - for _, table := range checker.tableSet { +// countCurrentTimeColumn increments the per-table counts for a single +// column. Only DATETIME/TIMESTAMP columns are counted; the omniIsTimeType +// gate matches pingcap-typed predecessor's TypeDatetime/TypeTimestamp +// switch. +func countCurrentTimeColumn(tableSet map[string]currentTimeTableData, tableName string, column *ast.ColumnDef, line int) { + if column == nil || !omniIsTimeType(column.TypeName) { + return + } + if omniIsDefaultCurrentTime(column) { + table, exists := tableSet[tableName] + if !exists { + table = currentTimeTableData{tableName: tableName} + } + table.defaultCurrentTimeCount++ + table.line = line + tableSet[tableName] = table + } + if omniIsOnUpdateCurrentTime(column) { + table, exists := tableSet[tableName] + if !exists { + table = currentTimeTableData{tableName: tableName} + } + table.onUpdateCurrentTimeCount++ + table.line = line + tableSet[tableName] = table + } +} + +// generateCurrentTimeAdvice emits one advice per (table, category) +// where the count exceeds the limit, sorted by line for deterministic +// output. Mirrors pingcap-typed predecessor's generateAdvice exactly. +func generateCurrentTimeAdvice(tableSet map[string]currentTimeTableData, level storepb.Advice_Status, title string) []*storepb.Advice { + var tableList []currentTimeTableData + for _, table := range tableSet { tableList = append(tableList, table) } - slices.SortFunc(tableList, func(i, j tableData) int { - if i.line < j.line { + slices.SortFunc(tableList, func(a, b currentTimeTableData) int { + if a.line < b.line { return -1 } - if i.line > j.line { + if a.line > b.line { return 1 } return 0 }) + + var adviceList []*storepb.Advice for _, table := range tableList { if table.defaultCurrentTimeCount > maxDefaultCurrentTimeColumCount { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, + adviceList = append(adviceList, &storepb.Advice{ + Status: level, Code: code.DefaultCurrentTimeColumnCountExceedsLimit.Int32(), - Title: checker.title, + Title: title, Content: fmt.Sprintf("Table `%s` has %d DEFAULT CURRENT_TIMESTAMP() columns. The count greater than %d.", table.tableName, table.defaultCurrentTimeCount, maxDefaultCurrentTimeColumCount), StartPosition: common.ConvertANTLRLineToPosition(table.line), }) } if table.onUpdateCurrentTimeCount > maxOnUpdateCurrentTimeColumnCount { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, + adviceList = append(adviceList, &storepb.Advice{ + Status: level, Code: code.OnUpdateCurrentTimeColumnCountExceedsLimit.Int32(), - Title: checker.title, + Title: title, Content: fmt.Sprintf("Table `%s` has %d ON UPDATE CURRENT_TIMESTAMP() columns. The count greater than %d.", table.tableName, table.onUpdateCurrentTimeCount, maxOnUpdateCurrentTimeColumnCount), StartPosition: common.ConvertANTLRLineToPosition(table.line), }) } } - - return checker.adviceList -} - -func (checker *columnCurrentTimeCountLimitChecker) count(tableName string, column *ast.ColumnDef, line int) { - switch column.Tp.GetType() { - case mysql.TypeDatetime, mysql.TypeTimestamp: - if isDefaultCurrentTime(column) { - table, exists := checker.tableSet[tableName] - if !exists { - table = tableData{ - tableName: tableName, - } - } - table.defaultCurrentTimeCount++ - table.line = line - checker.tableSet[tableName] = table - } - if isOnUpdateCurrentTime(column) { - table, exists := checker.tableSet[tableName] - if !exists { - table = tableData{ - tableName: tableName, - } - } - table.onUpdateCurrentTimeCount++ - table.line = line - checker.tableSet[tableName] = table - } - default: - // Other column types - } -} - -// Enter implements the ast.Visitor interface. -func (checker *columnCurrentTimeCountLimitChecker) Enter(in ast.Node) (ast.Node, bool) { - switch node := in.(type) { - case *ast.CreateTableStmt: - tableName := node.Table.Name.O - for _, column := range node.Cols { - checker.count(tableName, column, node.OriginTextPosition()) - } - case *ast.AlterTableStmt: - tableName := node.Table.Name.O - for _, spec := range node.Specs { - switch spec.Tp { - case ast.AlterTableAddColumns: - for _, column := range spec.NewColumns { - checker.count(tableName, column, node.OriginTextPosition()) - } - case ast.AlterTableModifyColumn, ast.AlterTableChangeColumn: - checker.count(tableName, spec.NewColumns[0], node.OriginTextPosition()) - default: - } - } - default: - } - - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*columnCurrentTimeCountLimitChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true -} - -func isOnUpdateCurrentTime(column *ast.ColumnDef) bool { - for _, option := range column.Options { - if option.Tp == ast.ColumnOptionOnUpdate { - if function, ok := option.Expr.(*ast.FuncCallExpr); ok && isCurrentTime(function.FnName.L) { - return true - } - } - } - return false -} - -func isDefaultCurrentTime(column *ast.ColumnDef) bool { - for _, option := range column.Options { - if option.Tp == ast.ColumnOptionDefaultValue { - if function, ok := option.Expr.(*ast.FuncCallExpr); ok && isCurrentTime(function.FnName.L) { - return true - } - } - } - return false -} - -func isCurrentTime(name string) bool { - switch strings.ToLower(name) { - // Any of the synonyms for CURRENT_TIMESTAMP have the same meaning as CURRENT_TIMESTAMP. - // These are CURRENT_TIMESTAMP(), NOW(), LOCALTIME, LOCALTIME(), LOCALTIMESTAMP, and LOCALTIMESTAMP(). - // See https://dev.mysql.com/doc/refman/8.0/en/timestamp-initialization.html. - case "current_timestamp", "now", "localtime", "localtimestamp": - return true - default: - } - return false + return adviceList } diff --git a/backend/plugin/advisor/tidb/advisor_column_maximum_character_length.go b/backend/plugin/advisor/tidb/advisor_column_maximum_character_length.go index c8c64f1818da81..788eccd19ae069 100644 --- a/backend/plugin/advisor/tidb/advisor_column_maximum_character_length.go +++ b/backend/plugin/advisor/tidb/advisor_column_maximum_character_length.go @@ -1,26 +1,20 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" + "github.com/bytebase/omni/tidb/ast" "github.com/pkg/errors" - "github.com/bytebase/bytebase/backend/plugin/advisor/code" - - "github.com/pingcap/tidb/pkg/parser/ast" - "github.com/pingcap/tidb/pkg/parser/mysql" - "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" + "github.com/bytebase/bytebase/backend/plugin/advisor/code" ) var ( _ advisor.Advisor = (*ColumnMaximumCharacterLengthAdvisor)(nil) - _ ast.Visitor = (*columnMaximumCharacterLengthChecker)(nil) ) func init() { @@ -33,8 +27,7 @@ type ColumnMaximumCharacterLengthAdvisor struct { // Check checks for maximum character length. func (*ColumnMaximumCharacterLengthAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -47,94 +40,91 @@ func (*ColumnMaximumCharacterLengthAdvisor) Check(_ context.Context, checkCtx ad if numberPayload == nil { return nil, errors.New("number_payload is required for column maximum character length rule") } - checker := &columnMaximumCharacterLengthChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - maximum: int(numberPayload.Number), - } - - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + maximum := int(numberPayload.Number) + title := checkCtx.Rule.Type.String() + + var adviceList []*storepb.Advice + for _, ostmt := range stmts { + advice := checkStmtForCharLength(ostmt, maximum, level, title) + if advice != nil { + adviceList = append(adviceList, advice) + } } - return checker.adviceList, nil + return adviceList, nil } -type columnMaximumCharacterLengthChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int - maximum int -} - -// Enter implements the ast.Visitor interface. -func (checker *columnMaximumCharacterLengthChecker) Enter(in ast.Node) (ast.Node, bool) { - var tableName, columnName string - var line int - switch node := in.(type) { +// checkStmtForCharLength returns at most ONE advice per top-level +// statement, mirroring pingcap-typed predecessor's `break`-after-first- +// match cardinality contract. Mysql analog emits per-column (no break) +// — cardinality divergence preserved on the tidb side per invariant #7. +// +// Rule fires on CHAR or BINARY columns whose length exceeds the +// configured maximum. Cumulative #22 territory: pingcap's +// `mysql.TypeString` covered BOTH CHAR and BINARY (charset-pair +// unification); my omni port matches both via omniIsCharOrBinaryType. +// MySQL's "max character length" rule conceptually applies to CHAR; +// extending to BINARY preserves the pingcap behavior even though it's +// semantically odd (BINARY length is bytes, not characters). +func checkStmtForCharLength(ostmt OmniStmt, maximum int, level storepb.Advice_Status, title string) *storepb.Advice { + if maximum <= 0 { + return nil + } + switch n := ostmt.Node.(type) { case *ast.CreateTableStmt: - for _, column := range node.Cols { - charLength := getCharLength(column) - if checker.maximum > 0 && charLength > checker.maximum { - tableName = node.Table.Name.O - columnName = column.Name.Name.O - line = column.OriginTextPosition() - break + if n.Table == nil { + return nil + } + tableName := n.Table.Name + for _, column := range n.Columns { + if column == nil { + continue + } + if charLength := omniCharLength(column.TypeName); charLength > maximum { + return buildCharLengthAdvice(level, title, tableName, column.Name, charLength, maximum, ostmt.AbsoluteLine(column.Loc.Start)) } } case *ast.AlterTableStmt: - for _, spec := range node.Specs { - switch spec.Tp { - case ast.AlterTableAddColumns: - for _, column := range spec.NewColumns { - charLength := getCharLength(column) - if checker.maximum > 0 && charLength > checker.maximum { - tableName = node.Table.Name.O - columnName = column.Name.Name.O - line = node.OriginTextPosition() + if n.Table == nil { + return nil + } + tableName := n.Table.Name + stmtLine := ostmt.AbsoluteLine(n.Loc.Start) + for _, cmd := range n.Commands { + if cmd == nil { + continue + } + switch cmd.Type { + case ast.ATAddColumn: + for _, column := range addColumnTargets(cmd) { + if column == nil { + continue + } + if charLength := omniCharLength(column.TypeName); charLength > maximum { + return buildCharLengthAdvice(level, title, tableName, column.Name, charLength, maximum, stmtLine) } } - case ast.AlterTableChangeColumn, ast.AlterTableModifyColumn: - charLength := getCharLength(spec.NewColumns[0]) - if checker.maximum > 0 && charLength > checker.maximum { - tableName = node.Table.Name.O - columnName = spec.NewColumns[0].Name.Name.O - line = node.OriginTextPosition() + case ast.ATChangeColumn, ast.ATModifyColumn: + if cmd.Column == nil { + continue + } + if charLength := omniCharLength(cmd.Column.TypeName); charLength > maximum { + return buildCharLengthAdvice(level, title, tableName, cmd.Column.Name, charLength, maximum, stmtLine) } default: } - if tableName != "" { - break - } } default: } - - if tableName != "" { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.CharLengthExceedsLimit.Int32(), - Title: checker.title, - Content: fmt.Sprintf("The length of the CHAR column `%s` is bigger than %d, please use VARCHAR instead", columnName, checker.maximum), - StartPosition: common.ConvertANTLRLineToPosition(line), - }) - } - - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*columnMaximumCharacterLengthChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true + return nil } -func getCharLength(column *ast.ColumnDef) int { - if column.Tp.GetType() == mysql.TypeString { - return column.Tp.GetFlen() +func buildCharLengthAdvice(level storepb.Advice_Status, title, _, columnName string, charLength, maximum, line int) *storepb.Advice { + return &storepb.Advice{ + Status: level, + Code: code.CharLengthExceedsLimit.Int32(), + Title: title, + Content: fmt.Sprintf("The length of the CHAR column `%s` is %d, bigger than %d, please use VARCHAR instead", columnName, charLength, maximum), + StartPosition: common.ConvertANTLRLineToPosition(line), } - return 0 } diff --git a/backend/plugin/advisor/tidb/test/column_maximum_character_length.yaml b/backend/plugin/advisor/tidb/test/column_maximum_character_length.yaml index 253d9ee34a2eee..d10a0562d56eb3 100644 --- a/backend/plugin/advisor/tidb/test/column_maximum_character_length.yaml +++ b/backend/plugin/advisor/tidb/test/column_maximum_character_length.yaml @@ -8,7 +8,7 @@ - status: 2 code: 415 title: COLUMN_MAXIMUM_CHARACTER_LENGTH - content: The length of the CHAR column `name` is bigger than 20, please use VARCHAR instead + content: The length of the CHAR column `name` is 225, bigger than 20, please use VARCHAR instead startposition: line: 1 column: 0 @@ -19,7 +19,7 @@ - status: 2 code: 415 title: COLUMN_MAXIMUM_CHARACTER_LENGTH - content: The length of the CHAR column `name_2` is bigger than 20, please use VARCHAR instead + content: The length of the CHAR column `name_2` is 225, bigger than 20, please use VARCHAR instead startposition: line: 1 column: 0 @@ -30,7 +30,7 @@ - status: 2 code: 415 title: COLUMN_MAXIMUM_CHARACTER_LENGTH - content: The length of the CHAR column `name` is bigger than 20, please use VARCHAR instead + content: The length of the CHAR column `name` is 225, bigger than 20, please use VARCHAR instead startposition: line: 1 column: 0 @@ -41,7 +41,42 @@ - status: 2 code: 415 title: COLUMN_MAXIMUM_CHARACTER_LENGTH - content: The length of the CHAR column `name` is bigger than 20, please use VARCHAR instead + content: The length of the CHAR column `name` is 225, bigger than 20, please use VARCHAR instead + startposition: + line: 1 + column: 0 + endposition: null +# Cumulative #22 pin (batch 13): pingcap's mysql.TypeString covered +# BOTH CHAR and BINARY (charset-pair unification, same shape as #18 +# BLOB/TEXT). The pingcap-tidb rule fired on BINARY(N>maximum) too; +# a mechanical port matching only "CHAR" would drop BINARY coverage +# (mysql analog had this latent gap — pre-omni mysql also missed it +# via ANTLR token symbols; long-standing mysql gap, not regression +# per invariant #10). This fixture locks the BINARY-coverage contract +# on tidb. +- statement: CREATE TABLE t(blob_col binary(225)) + changeType: 1 + want: + - status: 2 + code: 415 + title: COLUMN_MAXIMUM_CHARACTER_LENGTH + content: The length of the CHAR column `blob_col` is 225, bigger than 20, please use VARCHAR instead + startposition: + line: 1 + column: 0 + endposition: null +# Single-advice-per-statement cardinality pin (batch 13): pingcap-tidb's +# Visitor broke after the first CHAR/BINARY column exceeding the limit +# — ONE advice per top-level statement. Mysql analog emits per-column. +# Tidb preserves pingcap behavior. Multi-column fixture verifies exactly +# 1 advice for a statement with TWO violating columns. +- statement: CREATE TABLE t(a char(225), b binary(225)) + changeType: 1 + want: + - status: 2 + code: 415 + title: COLUMN_MAXIMUM_CHARACTER_LENGTH + content: The length of the CHAR column `a` is 225, bigger than 20, please use VARCHAR instead startposition: line: 1 column: 0 diff --git a/backend/plugin/advisor/tidb/utils.go b/backend/plugin/advisor/tidb/utils.go index 28aceb81094f4a..98fdb0966c8be3 100644 --- a/backend/plugin/advisor/tidb/utils.go +++ b/backend/plugin/advisor/tidb/utils.go @@ -348,6 +348,110 @@ func firstAlterCommandMatching(n *omniast.AlterTableStmt, matcher func(*omniast. return -1 } +// omniIsTimeType reports whether the column type is DATETIME or +// TIMESTAMP. Pingcap dispatched on `mysql.TypeDatetime` / +// `mysql.TypeTimestamp` — distinct type bytes, no unification. +// omni surfaces both as their own DataType.Name with no aliasing. +func omniIsTimeType(dt *omniast.DataType) bool { + if dt == nil { + return false + } + switch strings.ToUpper(dt.Name) { + case "DATETIME", "TIMESTAMP": + return true + default: + return false + } +} + +// omniIsDefaultCurrentTime reports whether a column has +// `DEFAULT CURRENT_TIMESTAMP` (or one of its synonyms: NOW(), +// LOCALTIME, LOCALTIMESTAMP). The omni AST puts the default +// expression on `col.DefaultValue` (as an ExprNode); a function-call +// default surfaces as `*omniast.FuncCallExpr` with `Name` carrying +// the function token. +func omniIsDefaultCurrentTime(col *omniast.ColumnDef) bool { + if col == nil { + return false + } + return omniIsCurrentTimeFuncCall(col.DefaultValue) +} + +// omniIsOnUpdateCurrentTime reports whether a column has +// `ON UPDATE CURRENT_TIMESTAMP` (or its synonyms). Omni surfaces +// the on-update expression on `col.OnUpdate` — a separate top-level +// field on ColumnDef (NOT inside Constraints[]). +func omniIsOnUpdateCurrentTime(col *omniast.ColumnDef) bool { + if col == nil { + return false + } + return omniIsCurrentTimeFuncCall(col.OnUpdate) +} + +// omniIsCurrentTimeFuncCall checks whether the given expression is a +// function call to one of the CURRENT_TIMESTAMP synonyms: +// CURRENT_TIMESTAMP, NOW, LOCALTIME, LOCALTIMESTAMP. Pingcap +// canonicalized these to lowercase via `FnName.L`; omni keeps the +// user's original case in `FuncCallExpr.Name`, so we compare +// case-insensitively. +func omniIsCurrentTimeFuncCall(expr omniast.ExprNode) bool { + if expr == nil { + return false + } + fc, ok := expr.(*omniast.FuncCallExpr) + if !ok { + return false + } + switch strings.ToUpper(fc.Name) { + case "NOW", "CURRENT_TIMESTAMP", "LOCALTIME", "LOCALTIMESTAMP": + return true + default: + return false + } +} + +// omniIsCharOrBinaryType reports whether the column type is a +// fixed-length character/binary type (CHAR or BINARY). Pingcap-tidb +// dispatched on `mysql.TypeString` which covered BOTH `CHAR` and +// `BINARY` via charset distinction (charset-pair unification, same +// shape as cumulative #18 BLOB/TEXT and #20 TINYINT/BOOLEAN). Omni +// splits to distinct `DataType.Name = "CHAR"` and `"BINARY"`. +// Cumulative #22 — both must be matched to preserve pingcap behavior. +// Mysql analog only matches CHAR (long-standing mysql gap; pre-omni +// mysql used ANTLR token symbols and never matched BINARY either — +// NOT a regression). +func omniIsCharOrBinaryType(dt *omniast.DataType) bool { + if dt == nil { + return false + } + switch strings.ToUpper(dt.Name) { + case "CHAR", "BINARY": + return true + default: + return false + } +} + +// omniCharLength returns the declared length of a CHAR/BINARY column, +// applying the MySQL default of 1 when no explicit length is given. +// Pingcap's `column.Tp.GetFlen()` returned the canonical default +// width for bare CHAR / BARE BINARY; omni keeps Length=0 in that +// case. Matches both omni-builder behavior and INFORMATION_SCHEMA +// rendering. +// +// Returns 0 for non-CHAR/BINARY types — callers gate on +// omniIsCharOrBinaryType first. +func omniCharLength(dt *omniast.DataType) int { + if dt == nil || !omniIsCharOrBinaryType(dt) { + return 0 + } + if dt.Length == 0 { + // MySQL default: bare CHAR → CHAR(1); bare BINARY → BINARY(1). + return 1 + } + return dt.Length +} + // omniIsIntegerType reports whether the column type is an integer type // from the perspective of pingcap-tidb's `isInteger` helper. Pingcap // dispatched on `mysql.TypeTiny`/`TypeShort`/`TypeInt24`/`TypeLong`/`TypeLonglong` diff --git a/backend/plugin/advisor/tidb/utils_test.go b/backend/plugin/advisor/tidb/utils_test.go index d667b3e365d981..91d00f47e66c2a 100644 --- a/backend/plugin/advisor/tidb/utils_test.go +++ b/backend/plugin/advisor/tidb/utils_test.go @@ -291,3 +291,114 @@ func TestOmniColumnHasComment_PresentAbsentDistinction(t *testing.T) { require.True(t, omniColumnHasComment(regularCommentCol), "explicit COMMENT with value: omniColumnHasComment must return true") } + +// TestOmniIsTimeType pins the DATETIME/TIMESTAMP detection for +// column_current_time_count_limit (batch 13). Pingcap dispatched +// on mysql.TypeDatetime/TypeTimestamp — distinct type bytes, +// no unification concern. +func TestOmniIsTimeType(t *testing.T) { + cases := []struct { + name string + dt *omniast.DataType + want bool + }{ + {"DATETIME", &omniast.DataType{Name: "DATETIME"}, true}, + {"TIMESTAMP", &omniast.DataType{Name: "TIMESTAMP"}, true}, + {"lowercase datetime", &omniast.DataType{Name: "datetime"}, true}, + {"DATE", &omniast.DataType{Name: "DATE"}, false}, + {"TIME", &omniast.DataType{Name: "TIME"}, false}, + {"YEAR", &omniast.DataType{Name: "YEAR"}, false}, + {"VARCHAR", &omniast.DataType{Name: "VARCHAR"}, false}, + {"nil", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, omniIsTimeType(tc.dt)) + }) + } +} + +// TestOmniIsCurrentTimeFuncCall covers the CURRENT_TIMESTAMP synonym +// detection used by column_current_time_count_limit. Pingcap used +// FnName.L (lowercased); omni keeps user case in FuncCallExpr.Name, +// so we compare case-insensitively. +func TestOmniIsCurrentTimeFuncCall(t *testing.T) { + cases := []struct { + name string + expr omniast.ExprNode + want bool + }{ + {"CURRENT_TIMESTAMP uppercase", &omniast.FuncCallExpr{Name: "CURRENT_TIMESTAMP"}, true}, + {"current_timestamp lowercase", &omniast.FuncCallExpr{Name: "current_timestamp"}, true}, + {"NOW", &omniast.FuncCallExpr{Name: "NOW"}, true}, + {"LOCALTIME", &omniast.FuncCallExpr{Name: "LOCALTIME"}, true}, + {"LOCALTIMESTAMP", &omniast.FuncCallExpr{Name: "LOCALTIMESTAMP"}, true}, + {"UTC_TIMESTAMP (not synonym)", &omniast.FuncCallExpr{Name: "UTC_TIMESTAMP"}, false}, + {"unknown function", &omniast.FuncCallExpr{Name: "FOO"}, false}, + {"nil expr", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, omniIsCurrentTimeFuncCall(tc.expr)) + }) + } +} + +// TestOmniIsCharOrBinaryType pins cumulative #22 — pingcap's +// `mysql.TypeString` covered BOTH CHAR and BINARY via charset +// distinction. The omni port must match both names; a mechanical +// port matching only "CHAR" (like the mysql analog does) silently +// drops BINARY coverage. Same shape as cumulative #18 (BLOB/TEXT +// under TypeBlob) and #20 (TINYINT/BOOLEAN under TypeTiny). +// +// VARCHAR/VARBINARY are TypeVarString in pingcap, NOT TypeString — +// pingcap rule did NOT fire on those. Pin the negative case too. +func TestOmniIsCharOrBinaryType(t *testing.T) { + cases := []struct { + name string + dt *omniast.DataType + want bool + }{ + {"CHAR", &omniast.DataType{Name: "CHAR"}, true}, + {"BINARY", &omniast.DataType{Name: "BINARY"}, true}, + {"lowercase char", &omniast.DataType{Name: "char"}, true}, + {"lowercase binary", &omniast.DataType{Name: "binary"}, true}, + {"VARCHAR (negative)", &omniast.DataType{Name: "VARCHAR"}, false}, + {"VARBINARY (negative)", &omniast.DataType{Name: "VARBINARY"}, false}, + {"TEXT", &omniast.DataType{Name: "TEXT"}, false}, + {"BLOB", &omniast.DataType{Name: "BLOB"}, false}, + {"nil", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, omniIsCharOrBinaryType(tc.dt)) + }) + } +} + +// TestOmniCharLength pins the MySQL default-1 application for bare +// CHAR / BINARY columns. Pingcap's column.Tp.GetFlen() returned the +// canonical default (1) for bare CHAR; omni leaves Length=0, so the +// helper must apply the default explicitly to preserve pingcap +// behavior on column_maximum_character_length. +func TestOmniCharLength(t *testing.T) { + cases := []struct { + name string + dt *omniast.DataType + want int + }{ + {"bare CHAR → 1", &omniast.DataType{Name: "CHAR"}, 1}, + {"bare BINARY → 1", &omniast.DataType{Name: "BINARY"}, 1}, + {"CHAR(10)", &omniast.DataType{Name: "CHAR", Length: 10}, 10}, + {"BINARY(16)", &omniast.DataType{Name: "BINARY", Length: 16}, 16}, + {"CHAR(255)", &omniast.DataType{Name: "CHAR", Length: 255}, 255}, + {"VARCHAR(255) → 0", &omniast.DataType{Name: "VARCHAR", Length: 255}, 0}, + {"INT → 0", &omniast.DataType{Name: "INT"}, 0}, + {"nil → 0", nil, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, omniCharLength(tc.dt)) + }) + } +} From 9691da83b662e99c1883c6101ea04ae319be6038 Mon Sep 17 00:00:00 2001 From: boojack Date: Tue, 12 May 2026 16:29:56 +0800 Subject: [PATCH 019/127] refactor(react): migrate Watermark from Vue to React (#20315) Replaces the Vue Watermark.vue (built on naive-ui's NWatermark) with a React Watermark.tsx mounted via ReactPageMount from App.vue. The new implementation uses a canvas-generated data URL as a repeating CSS background (no third-party dep) and reads state through the existing React hooks useServerInfo / useWorkspaceProfile / useCurrentUser / usePlanFeature. Watermark.tsx is added to the layering scanner allowlist as a legitimate shell-level fixed overlay, mirroring SessionExpiredSurface. Co-authored-by: Claude Opus 4.7 (1M context) --- frontend/scripts/check-react-layering.mjs | 1 + frontend/src/App.vue | 4 +- frontend/src/components/misc/Watermark.vue | 82 ----------- frontend/src/react/components/Watermark.tsx | 143 ++++++++++++++++++++ frontend/src/shell-bridge.test.ts | 5 +- 5 files changed, 149 insertions(+), 86 deletions(-) delete mode 100644 frontend/src/components/misc/Watermark.vue create mode 100644 frontend/src/react/components/Watermark.tsx diff --git a/frontend/scripts/check-react-layering.mjs b/frontend/scripts/check-react-layering.mjs index 6f84e3d49ff1b1..5d352adeb1ff71 100644 --- a/frontend/scripts/check-react-layering.mjs +++ b/frontend/scripts/check-react-layering.mjs @@ -28,6 +28,7 @@ const APPROVED_PREFIXES = [ const APPROVED_FILES = new Set([ "src/react/components/auth/SessionExpiredSurface.tsx", + "src/react/components/Watermark.tsx", ]); const CLASS_ATTR_PATTERN = /\bclass(Name)?\s*=\s*/g; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 94954d72abdca6..41c5a36f9f9726 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,7 +4,7 @@ :date-locale="dateLang" :theme-overrides="themeOverrides" > - + - - - - - diff --git a/frontend/src/react/components/Watermark.tsx b/frontend/src/react/components/Watermark.tsx new file mode 100644 index 00000000000000..f0a6bc6c2ea803 --- /dev/null +++ b/frontend/src/react/components/Watermark.tsx @@ -0,0 +1,143 @@ +import type { CSSProperties } from "react"; +import { useMemo } from "react"; +import { + useCurrentUser, + usePlanFeature, + useServerInfo, + useWorkspaceProfile, +} from "@/react/hooks/useAppState"; +import { extractUserEmail } from "@/store/modules/v1/common"; +import { UNKNOWN_USER_NAME } from "@/types"; +import { PlanFeature } from "@/types/proto-es/v1/subscription_service_pb"; + +const USER_LAYER_CELL_W = 320; +const USER_LAYER_CELL_H = 200; +const USER_LAYER_FONT_SIZE = 16; +const USER_LAYER_LINE_STEP = 20; +const USER_LAYER_PADDING = 6; +const VERSION_LAYER_CELL = 128; +const VERSION_LAYER_FONT_SIZE = 16; + +const baseLayerStyle: CSSProperties = { + position: "fixed", + inset: 0, + pointerEvents: "none", + backgroundRepeat: "repeat", +}; + +function makeWatermarkDataURL(opts: { + content: string; + fontSize: number; + fontColor: string; + rotateDeg: number; + cellW: number; + cellH: number; +}): string { + const { content, fontSize, fontColor, rotateDeg, cellW, cellH } = opts; + if (typeof document === "undefined" || !content) return ""; + const dpr = window.devicePixelRatio || 1; + const canvas = document.createElement("canvas"); + canvas.width = cellW * dpr; + canvas.height = cellH * dpr; + const ctx = canvas.getContext("2d"); + if (!ctx) return ""; + ctx.scale(dpr, dpr); + ctx.font = `${fontSize}px sans-serif`; + ctx.fillStyle = fontColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.translate(cellW / 2, cellH / 2); + ctx.rotate((rotateDeg * Math.PI) / 180); + ctx.fillText(content, 0, 0); + return canvas.toDataURL(); +} + +export function Watermark() { + const currentUser = useCurrentUser(); + const hasWatermarkFeature = usePlanFeature(PlanFeature.FEATURE_WATERMARK); + const serverInfo = useServerInfo(); + const workspaceProfile = useWorkspaceProfile(); + + const version = useMemo(() => { + const v = serverInfo?.version ?? ""; + const commit = (serverInfo?.gitCommit ?? "").substring(0, 7); + return `${v}-${commit}`; + }, [serverInfo?.version, serverInfo?.gitCommit]); + + const userLines = useMemo(() => { + if ( + !currentUser || + currentUser.name === UNKNOWN_USER_NAME || + !hasWatermarkFeature || + !workspaceProfile?.watermark + ) { + return []; + } + const uid = extractUserEmail(currentUser.name); + const lines = [`${currentUser.title} (${uid})`]; + if (currentUser.email) lines.push(currentUser.email); + return lines; + }, [currentUser, hasWatermarkFeature, workspaceProfile?.watermark]); + + const versionDataURL = useMemo( + () => + makeWatermarkDataURL({ + content: version, + fontSize: VERSION_LAYER_FONT_SIZE, + fontColor: "rgba(255, 128, 128, 0.01)", + rotateDeg: 15, + cellW: VERSION_LAYER_CELL, + cellH: VERSION_LAYER_CELL, + }), + [version] + ); + + const userDataURLs = useMemo( + () => + userLines.map((line) => + makeWatermarkDataURL({ + content: line, + fontSize: USER_LAYER_FONT_SIZE, + fontColor: "rgba(128, 128, 128, 0.1)", + rotateDeg: -15, + cellW: USER_LAYER_CELL_W, + cellH: USER_LAYER_CELL_H, + }) + ), + [userLines] + ); + + return ( + <> + {versionDataURL && ( +

- diff --git a/proto/v1/v1/rollout_service.proto b/proto/v1/v1/rollout_service.proto index 649df4a9046a12..8f5f5f4e6e845b 100644 --- a/proto/v1/v1/rollout_service.proto +++ b/proto/v1/v1/rollout_service.proto @@ -105,7 +105,10 @@ service RolloutService { option (bytebase.v1.audit) = true; } - // Cancels multiple running task executions. + // Cancels multiple task runs. + // PENDING and AVAILABLE task runs are moved to CANCELED synchronously. RUNNING task runs receive + // a best-effort cancellation request and may continue running if the request is missed or the + // executor does not stop. The response does not report which task runs were actually canceled. // Permissions required: bb.taskRuns.create (or issue creator for data export issues, or user with rollout policy role for the environment) rpc BatchCancelTaskRuns(BatchCancelTaskRunsRequest) returns (BatchCancelTaskRunsResponse) { option (google.api.http) = { From c7a24c196ab56fa3c7414355640d8a781d60a76a Mon Sep 17 00:00:00 2001 From: boojack Date: Tue, 12 May 2026 16:54:55 +0800 Subject: [PATCH 021/127] chore(react): drop NDialogProvider and dead Vue instance table (#20316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(react): drop NDialogProvider and dead Vue instance table The only remaining consumer of naive-ui's NDialogProvider/useDialog was the Vue InstanceActionDropdown, which itself was only reachable through the long-dead InstanceV1Table → PagedInstanceTable chain. Their React counterparts are already in use on InstancesPage and InstanceDetailPage. This removes the provider from App.vue, drops the matching test mock, and deletes the five dead Vue files plus the three unused barrel re-exports. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(i18n): drop instance.restore + instance.selected-n-instances These keys were only consumed by the Vue surfaces deleted in the previous commit. The Vue locale linter (@intlify/vue-i18n/no-unused-keys) flagged them in CI. React surfaces use their own locale tree. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(i18n): also drop the two unused keys from vi-VN I missed vi-VN in the previous locale cleanup. CI's stricter `pnpm check` caught it via @intlify/vue-i18n/no-unused-keys, and i18n.test.ts caught it via the cross-locale key-set consistency assertion. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- frontend/src/App.vue | 22 +- .../Instance/InstanceActionDropdown.vue | 175 ----------- .../InstanceV1Table/InstanceOperations.vue | 179 ----------- .../InstanceV1Table/InstanceV1Table.vue | 297 ------------------ .../Model/Instance/InstanceV1Table/index.ts | 3 - .../v2/Model/Instance/PagedInstanceTable.vue | 80 ----- .../src/components/v2/Model/Instance/index.ts | 6 - frontend/src/locales/en-US.json | 2 - frontend/src/locales/es-ES.json | 2 - frontend/src/locales/ja-JP.json | 2 - frontend/src/locales/vi-VN.json | 2 - frontend/src/locales/zh-CN.json | 2 - frontend/src/shell-bridge.test.ts | 1 - 13 files changed, 8 insertions(+), 765 deletions(-) delete mode 100644 frontend/src/components/Instance/InstanceActionDropdown.vue delete mode 100644 frontend/src/components/v2/Model/Instance/InstanceV1Table/InstanceOperations.vue delete mode 100644 frontend/src/components/v2/Model/Instance/InstanceV1Table/InstanceV1Table.vue delete mode 100644 frontend/src/components/v2/Model/Instance/InstanceV1Table/index.ts delete mode 100644 frontend/src/components/v2/Model/Instance/PagedInstanceTable.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 41c5a36f9f9726..8cd9572880c3cf 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,15 +10,13 @@ :max="MAX_NOTIFICATION_DISPLAY_COUNT" placement="bottom-right" > - - - - - - - - - + + + + + + + @@ -26,11 +24,7 @@ diff --git a/frontend/src/components/v2/Model/Instance/InstanceV1Table/InstanceOperations.vue b/frontend/src/components/v2/Model/Instance/InstanceV1Table/InstanceOperations.vue deleted file mode 100644 index 79011bafffc2f0..00000000000000 --- a/frontend/src/components/v2/Model/Instance/InstanceV1Table/InstanceOperations.vue +++ /dev/null @@ -1,179 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/Instance/InstanceV1Table/InstanceV1Table.vue b/frontend/src/components/v2/Model/Instance/InstanceV1Table/InstanceV1Table.vue deleted file mode 100644 index 39e88c1bf5b97c..00000000000000 --- a/frontend/src/components/v2/Model/Instance/InstanceV1Table/InstanceV1Table.vue +++ /dev/null @@ -1,297 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/Instance/InstanceV1Table/index.ts b/frontend/src/components/v2/Model/Instance/InstanceV1Table/index.ts deleted file mode 100644 index cfc7bf21deb979..00000000000000 --- a/frontend/src/components/v2/Model/Instance/InstanceV1Table/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import InstanceV1Table from "./InstanceV1Table.vue"; - -export default InstanceV1Table; diff --git a/frontend/src/components/v2/Model/Instance/PagedInstanceTable.vue b/frontend/src/components/v2/Model/Instance/PagedInstanceTable.vue deleted file mode 100644 index 02ebd67d9ff747..00000000000000 --- a/frontend/src/components/v2/Model/Instance/PagedInstanceTable.vue +++ /dev/null @@ -1,80 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/Instance/index.ts b/frontend/src/components/v2/Model/Instance/index.ts index 0bc44ea71b9a63..5b22cf3b306983 100644 --- a/frontend/src/components/v2/Model/Instance/index.ts +++ b/frontend/src/components/v2/Model/Instance/index.ts @@ -2,18 +2,12 @@ import InstanceEngineRadioGrid from "./InstanceEngineRadioGrid.vue"; import InstanceRoleTable from "./InstanceRoleTable"; import InstanceV1EngineIcon from "./InstanceV1EngineIcon.vue"; import InstanceV1Name from "./InstanceV1Name.vue"; -import InstanceV1Table from "./InstanceV1Table"; -import InstanceOperations from "./InstanceV1Table/InstanceOperations.vue"; -import PagedInstanceTable from "./PagedInstanceTable.vue"; import RichEngineName from "./RichEngineName.vue"; export { InstanceEngineRadioGrid, InstanceV1Name, InstanceV1EngineIcon, - InstanceV1Table, InstanceRoleTable, RichEngineName, - PagedInstanceTable, - InstanceOperations, }; diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 0044f65f335f52..a37d01727009a0 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -962,7 +962,6 @@ "password-write-only": "YOUR_DB_PWD - write only", "port": "Port", "project-id": "Project ID", - "restore": "Restore", "restore-instance-instance-name-to-normal-state": "Restore instance '{0}' to normal state?", "role-arn": "Role ARN", "role-arn-description": "IAM role to assume for cross-account access. Leave empty for same-account access.", @@ -978,7 +977,6 @@ "connection": "Connection" }, "select-database-user": "Select database user", - "selected-n-instances": "{n} instance selected | {n} instances selected", "sentence": { "aws-rds": { "mysql": { diff --git a/frontend/src/locales/es-ES.json b/frontend/src/locales/es-ES.json index c5d643d3a13739..ef253748a4480c 100644 --- a/frontend/src/locales/es-ES.json +++ b/frontend/src/locales/es-ES.json @@ -962,7 +962,6 @@ "password-write-only": "SU_DB_PWD - solo escritura", "port": "Puerto", "project-id": "ID del proyecto", - "restore": "Restaurar", "restore-instance-instance-name-to-normal-state": "¿Restaurar la instancia '{0}' al estado normal?", "role-arn": "ARN del rol", "role-arn-description": "Rol IAM para asumir en acceso entre cuentas. Dejar vacío para acceso en la misma cuenta.", @@ -978,7 +977,6 @@ "connection": "Conexión" }, "select-database-user": "Seleccionar usuario de base de datos", - "selected-n-instances": "{n} instancia seleccionada | {n} instancias seleccionadas", "sentence": { "aws-rds": { "mysql": { diff --git a/frontend/src/locales/ja-JP.json b/frontend/src/locales/ja-JP.json index 701a241efee289..c17cab86e0d34e 100644 --- a/frontend/src/locales/ja-JP.json +++ b/frontend/src/locales/ja-JP.json @@ -962,7 +962,6 @@ "password-write-only": "YOUR_DB_PWD - 書き込み専用", "port": "ポート", "project-id": "プロジェクトID", - "restore": "復元する", "restore-instance-instance-name-to-normal-state": "インスタンス '{0}' を通常の状態に復元しますか?", "role-arn": "ロール ARN", "role-arn-description": "クロスアカウントアクセス用の IAM ロール。同一アカウントアクセスの場合は空のままにしてください。", @@ -978,7 +977,6 @@ "connection": "接続" }, "select-database-user": "データベースユーザーの選択", - "selected-n-instances": "{n} 個のインスタンスが選択されました", "sentence": { "aws-rds": { "mysql": { diff --git a/frontend/src/locales/vi-VN.json b/frontend/src/locales/vi-VN.json index 00fb94de247eaa..ecc16ff5e2902b 100644 --- a/frontend/src/locales/vi-VN.json +++ b/frontend/src/locales/vi-VN.json @@ -962,7 +962,6 @@ "password-write-only": "YOUR_DB_PWD - chỉ ghi", "port": "Cổng", "project-id": "ID dự án", - "restore": "Khôi phục", "restore-instance-instance-name-to-normal-state": "Khôi phục phiên bản '{0}' về trạng thái bình thường?", "role-arn": "ARN vai trò", "role-arn-description": "Vai trò IAM để đảm nhận cho truy cập xuyên tài khoản. Để trống cho truy cập cùng tài khoản.", @@ -978,7 +977,6 @@ "connection": "Kết nối" }, "select-database-user": "Chọn người dùng cơ sở dữ liệu", - "selected-n-instances": "{n} phiên bản đã chọn | {n} phiên bản đã chọn", "sentence": { "aws-rds": { "mysql": { diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index dad873aeb3a1a0..4bbbadd2b8a794 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -962,7 +962,6 @@ "password-write-only": "YOUR_DB_PWD - 仅写入", "port": "端口", "project-id": "项目 ID", - "restore": "恢复", "restore-instance-instance-name-to-normal-state": "恢复实例'{0}'到正常状态?", "role-arn": "角色 ARN", "role-arn-description": "用于跨账户访问的 IAM 角色。同账户访问请留空。", @@ -978,7 +977,6 @@ "connection": "连接" }, "select-database-user": "选择数据库用户", - "selected-n-instances": "已选择 {n} 个实例", "sentence": { "aws-rds": { "mysql": { diff --git a/frontend/src/shell-bridge.test.ts b/frontend/src/shell-bridge.test.ts index f52fec9ad6f6a5..4cbbab45092acf 100644 --- a/frontend/src/shell-bridge.test.ts +++ b/frontend/src/shell-bridge.test.ts @@ -44,7 +44,6 @@ vi.mock("naive-ui", async () => { }); return { NConfigProvider: passthrough("NConfigProvider"), - NDialogProvider: passthrough("NDialogProvider"), NNotificationProvider: passthrough("NNotificationProvider"), useNotification: () => ({ create: mocks.notificationCreate, From 5d70830ab73643833c0b524c5d0255130a154f31 Mon Sep 17 00:00:00 2001 From: Vincent Huang <40749774+vsai12@users.noreply.github.com> Date: Tue, 12 May 2026 02:11:58 -0700 Subject: [PATCH 022/127] =?UTF-8?q?refactor(advisor/tidb):=20migrate=20par?= =?UTF-8?q?tition=20+=20order-by-rand=20pair=20to=20omni=20AST=20+=20cumul?= =?UTF-8?q?ative=20#23=20(Phase=201.5=20=C2=A71.5.3=20batch=2014)=20(#2031?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(advisor/tidb): preserve grouped ADD COLUMN last-wins on column_maximum_character_length (cumulative #23) Pingcap-tidb's pre-omni column_maximum_character_length had asymmetric loop control between two call-sites in the same Enter method: - CreateTableStmt arm: inner column loop with `break` → first-wins - AlterTableStmt ATAddColumns arm: inner column loop with NO break → last-wins (each subsequent violation overwrites the previous one; outer `break` fires only after the inner loop completes) Externally observable on grouped form: ALTER TABLE t ADD COLUMN (a CHAR(225), b CHAR(226)) before: advice mentions `b` after batch 13 migration: advice mentions `a` (regression) Initial batch 13 omni port (PR #20312) normalized to first-wins across both arms — preserved single-advice-per-statement cardinality but flipped which column got reported for grouped ALTER ADD COLUMN. Codex P3 caught post-merge. Fix: lastViolation tracker on the ATAddColumn inner loop, emit once after — same prescription as cumulative #15's "track lastViolation local, emit once after the loop" applied to a different call-site within the same advisor file. ATChangeColumn / ATModifyColumn are single-shot (no inner loop) and unchanged. Pinned with new grouped fixture: ALTER TABLE tech_book ADD COLUMN (a char(225), b char(226)) expects advice mentioning `b`. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(advisor/tidb): migrate partition + order-by-rand pair to omni AST (Phase 1.5 §1.5.3 batch 14) Two clean-shape advisors with Recipe A (top-level type-switch + direct field access). Step 2a clean — no type-byte territory. - advisor_table_disallow_partition: CreateTableStmt.Partitions != nil + AlterTableStmt commands scanned for ATPartitionBy. Pingcap's AlterTablePartition enum (the REPARTITION form: ALTER TABLE t PARTITION BY ...) maps directly to omni's ATPartitionBy. Partition-management forms (ADD PARTITION, DROP PARTITION, TRUNCATE PARTITION, etc.) are NOT covered by either pingcap-tidb or the omni port — long-standing pre-omni scope preserved per invariant #10. Mysql analog has identical scope. - advisor_insert_disallow_order_by_rand: InsertStmt.Select.OrderBy scanned for RAND() function calls. Preserves pingcap-tidb's break-on-first-match cardinality (one advice per statement, NOT per-item like the mysql analog) and the no-SetOp-recursion scope (UNION'd inserts skipped, matching long-standing pingcap-tidb behavior). New helper omniIsRandFuncCall in utils.go: case-insensitive RAND() detection via EqualFold on FuncCallExpr.Name (pingcap canonicalized to lowercase via FnName.L; omni preserves user case). Unit tests in utils_test.go cover uppercase/lowercase/titlecase, seed-arg variant, nil expr, and negative cases (RANDOM, NOT_RAND). Migration count: 37 → 39 omni-migrated; 10 remaining on pingcap (8 migratable + 1 Class III blocked + utils.go bridge). Co-Authored-By: Claude Opus 4.7 (1M context) * test(advisor/tidb): expand fixture pins for partition + order-by-rand (Phase 1.5 §1.5.3 batch 14) table_disallow_partition.yaml: - HASH partition positive — verifies coverage across partition methods (not just RANGE). - ALTER ... ADD PARTITION negative — pins the long-standing pre-omni scope (rule does NOT cover partition-management forms). - ALTER ... DROP PARTITION negative — same scope pin. statement_insert_disallow_order_by_rand.yaml: - Uppercase RAND() positive — case-insensitivity contract on omniIsRandFuncCall (pingcap lowercased via FnName.L; omni preserves user case; EqualFold restores parity). - INSERT ... SELECT ... ORDER BY id negative — distinguishes "any ORDER BY" from "ORDER BY RAND() specifically". - INSERT ... SET form negative — cumulative #1 territory; ins.Select is nil and the rule short-circuits before checking OrderBy. Co-Authored-By: Claude Opus 4.7 (1M context) * test(advisor/tidb): pin cumulative #24 — UNION outer-ORDER-BY UX improvement on insert_disallow_order_by_rand Peer review caught a silent behavior change at the UNION boundary that the initial batch 14 PR description elided. Pre-omni pingcap-tidb's `InsertStmt.Select` is a `ResultSetNode` interface. For `INSERT INTO t SELECT ... UNION SELECT ... ORDER BY RAND()` the concrete type is `*ast.SetOprStmt`, NOT `*ast.SelectStmt`. The pre-omni Enter's `insert.Select.(*ast.SelectStmt)` cast failed and the whole check was silently skipped — pre-omni rule did NOT flag UNION-result ORDER BY RAND despite the rule's name and stated intent. Omni's `InsertStmt.Select` is `*SelectStmt` (concrete pointer) regardless of SetOp value; UNION'd inputs surface as `SelectStmt{SetOp: SetOpUnion, Left: ..., Right: ..., OrderBy: [...]}`. Direct access to `ins.Select.OrderBy` lands on the outer-UNION ORDER BY list. Rule now fires — silent UX improvement matching the rule's actual intent. Same UX-improvement shape as cumulative #18 ("is text" advice content fix) and #21 (latent BLOB MODIFY false-positive). Verified empirically by parsing the same input through both pingcap- and omni-tidb parsers and inspecting `%T` / `SetOp` / `OrderBy[0]`. Updates: - Advisor comment now accurately describes outer-vs-inner UNION boundary distinction (outer-UNION OrderBy newly covered; per-arm parenthesized OrderBy remains uncovered — omni rejects that syntax in INSERT position, separate Phase 2 grammar gap). - New positive fixture pin asserting the rule fires on the un-parenthesized UNION form. NEW pre-batch audit axis added in plan-doc cumulative #24: when pingcap source uses a narrow type-assertion on an interface-typed field (e.g. `.(*ast.SelectStmt)` on `ResultSetNode`), the omni port may newly reach inputs the old code accidentally filtered out — the omni equivalent field is often concrete-typed, so direct access bypasses the implicit filter. Audit every type-assertion: ask "does this assertion narrow the input set?" Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- ...advisor_column_maximum_character_length.go | 17 ++- .../advisor_insert_disallow_order_by_rand.go | 108 +++++++++--------- .../tidb/advisor_table_disallow_partition.go | 90 +++++++-------- .../test/column_maximum_character_length.yaml | 19 +++ ...atement_insert_disallow_order_by_rand.yaml | 48 ++++++++ .../tidb/test/table_disallow_partition.yaml | 29 +++++ backend/plugin/advisor/tidb/utils.go | 19 +++ backend/plugin/advisor/tidb/utils_test.go | 27 +++++ 8 files changed, 250 insertions(+), 107 deletions(-) diff --git a/backend/plugin/advisor/tidb/advisor_column_maximum_character_length.go b/backend/plugin/advisor/tidb/advisor_column_maximum_character_length.go index 788eccd19ae069..0f809e8047df75 100644 --- a/backend/plugin/advisor/tidb/advisor_column_maximum_character_length.go +++ b/backend/plugin/advisor/tidb/advisor_column_maximum_character_length.go @@ -96,14 +96,29 @@ func checkStmtForCharLength(ostmt OmniStmt, maximum int, level storepb.Advice_St } switch cmd.Type { case ast.ATAddColumn: + // Cumulative #23: pingcap-tidb's pre-omni + // AlterTableAddColumns inner column loop had no break; + // LAST violating column in the grouped form overwrites + // and is reported (asymmetric vs CreateTableStmt which + // DOES break — first-wins). Track lastViolation across + // the inner loop, emit once after — mirrors cumulative + // #15's "preserve pingcap single-advice-per-stmt + // cardinality with last-wins semantics" prescription + // on the AlterTable ADD COLUMN call-site. + var lastCol *ast.ColumnDef + var lastLen int for _, column := range addColumnTargets(cmd) { if column == nil { continue } if charLength := omniCharLength(column.TypeName); charLength > maximum { - return buildCharLengthAdvice(level, title, tableName, column.Name, charLength, maximum, stmtLine) + lastCol = column + lastLen = charLength } } + if lastCol != nil { + return buildCharLengthAdvice(level, title, tableName, lastCol.Name, lastLen, maximum, stmtLine) + } case ast.ATChangeColumn, ast.ATModifyColumn: if cmd.Column == nil { continue diff --git a/backend/plugin/advisor/tidb/advisor_insert_disallow_order_by_rand.go b/backend/plugin/advisor/tidb/advisor_insert_disallow_order_by_rand.go index fd5d1eafe64a61..3241f7317616aa 100644 --- a/backend/plugin/advisor/tidb/advisor_insert_disallow_order_by_rand.go +++ b/backend/plugin/advisor/tidb/advisor_insert_disallow_order_by_rand.go @@ -1,12 +1,10 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" - "github.com/pingcap/tidb/pkg/parser/ast" + "github.com/bytebase/omni/tidb/ast" "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" @@ -16,7 +14,6 @@ import ( var ( _ advisor.Advisor = (*InsertDisallowOrderByRandAdvisor)(nil) - _ ast.Visitor = (*insertDisallowOrderByRandChecker)(nil) ) func init() { @@ -29,8 +26,7 @@ type InsertDisallowOrderByRandAdvisor struct { // Check checks for to disallow order by rand in INSERT statements. func (*InsertDisallowOrderByRandAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -39,62 +35,62 @@ func (*InsertDisallowOrderByRandAdvisor) Check(_ context.Context, checkCtx advis if err != nil { return nil, err } - checker := &insertDisallowOrderByRandChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - } + title := checkCtx.Rule.Type.String() - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + var adviceList []*storepb.Advice + for _, ostmt := range stmts { + advice := checkStmtForOrderByRand(ostmt, level, title) + if advice != nil { + adviceList = append(adviceList, advice) + } } - return checker.adviceList, nil + return adviceList, nil } -type insertDisallowOrderByRandChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int -} - -// Enter implements the ast.Visitor interface. -func (checker *insertDisallowOrderByRandChecker) Enter(in ast.Node) (ast.Node, bool) { - code := advisorcode.Ok - if insert, ok := in.(*ast.InsertStmt); ok { - if insert.Select != nil { - if selectNode, ok := insert.Select.(*ast.SelectStmt); ok { - if selectNode.OrderBy != nil { - for _, item := range selectNode.OrderBy.Items { - if f, ok := item.Expr.(*ast.FuncCallExpr); ok { - if f.FnName.L == ast.Rand { - code = advisorcode.InsertUseOrderByRand - break - } - } - } - } +// checkStmtForOrderByRand returns at most ONE advice per top-level +// statement, mirroring pingcap-typed predecessor's break-after-first- +// RAND-match cardinality. Mysql analog emits per-item (no break); +// tidb preserves pingcap-tidb's single-advice-per-stmt contract. +// +// Cumulative #1 framing: INSERT-VALUES / INSERT-SET / INSERT-TABLE +// forms have `ins.Select == nil` and skip. Only INSERT ... SELECT can +// have an ORDER BY; only that path is checked. +// +// Cumulative #24 — silent UX improvement at the UNION boundary: +// pingcap's `InsertStmt.Select` is a `ResultSetNode` interface; +// UNION'd inserts produce `*ast.SetOprStmt`. The pre-omni rule +// type-asserted `insert.Select.(*ast.SelectStmt)` — the cast failed +// for UNION'd inputs and silently skipped the whole check. Omni's +// `InsertStmt.Select` is `*SelectStmt` (concrete) regardless of +// SetOp, so `ins.Select.OrderBy` here IS the outer-UNION ORDER BY +// list. The rule now fires on `INSERT ... SELECT ... UNION ... +// ORDER BY RAND()` (outer-UNION position) — matches the rule's +// stated intent. NOT a regression. Inner per-arm OrderBy (in +// parenthesized UNION arms, if/when omni grammar accepts that +// syntax in INSERT position — currently rejected) remains +// uncovered, matching pingcap-tidb's per-arm behavior. +func checkStmtForOrderByRand(ostmt OmniStmt, level storepb.Advice_Status, title string) *storepb.Advice { + ins, ok := ostmt.Node.(*ast.InsertStmt) + if !ok { + return nil + } + if ins.Select == nil { + return nil + } + for _, item := range ins.Select.OrderBy { + if item == nil { + continue + } + if omniIsRandFuncCall(item.Expr) { + return &storepb.Advice{ + Status: level, + Code: advisorcode.InsertUseOrderByRand.Int32(), + Title: title, + Content: fmt.Sprintf("\"%s\" uses ORDER BY RAND in the INSERT statement", ostmt.TrimmedText()), + StartPosition: common.ConvertANTLRLineToPosition(ostmt.FirstTokenLine()), } } } - - if code != advisorcode.Ok { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.Int32(), - Title: checker.title, - Content: fmt.Sprintf("\"%s\" uses ORDER BY RAND in the INSERT statement", checker.text), - StartPosition: common.ConvertANTLRLineToPosition(checker.line), - }) - } - - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*insertDisallowOrderByRandChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true + return nil } diff --git a/backend/plugin/advisor/tidb/advisor_table_disallow_partition.go b/backend/plugin/advisor/tidb/advisor_table_disallow_partition.go index b594b611b60879..c026d13927f9c1 100644 --- a/backend/plugin/advisor/tidb/advisor_table_disallow_partition.go +++ b/backend/plugin/advisor/tidb/advisor_table_disallow_partition.go @@ -1,23 +1,19 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" - advisorcode "github.com/bytebase/bytebase/backend/plugin/advisor/code" - - "github.com/pingcap/tidb/pkg/parser/ast" + "github.com/bytebase/omni/tidb/ast" "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" + advisorcode "github.com/bytebase/bytebase/backend/plugin/advisor/code" ) var ( _ advisor.Advisor = (*TableDisallowPartitionAdvisor)(nil) - _ ast.Visitor = (*tableDisallowPartitionChecker)(nil) ) func init() { @@ -30,8 +26,7 @@ type TableDisallowPartitionAdvisor struct { // Check checks for disallow table partition. func (*TableDisallowPartitionAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -40,60 +35,55 @@ func (*TableDisallowPartitionAdvisor) Check(_ context.Context, checkCtx advisor. if err != nil { return nil, err } - checker := &tableDisallowPartitionChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - } + title := checkCtx.Rule.Type.String() - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + var adviceList []*storepb.Advice + for _, ostmt := range stmts { + advice := checkStmtForPartition(ostmt, level, title) + if advice != nil { + adviceList = append(adviceList, advice) + } } - return checker.adviceList, nil + return adviceList, nil } -type tableDisallowPartitionChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int -} - -// Enter implements the ast.Visitor interface. -func (checker *tableDisallowPartitionChecker) Enter(in ast.Node) (ast.Node, bool) { - code := advisorcode.Ok - switch node := in.(type) { +// checkStmtForPartition returns at most ONE advice per top-level +// statement, mirroring pingcap-typed predecessor's break-after-first- +// match cardinality. Pre-omni pingcap matched `spec.Tp == +// AlterTablePartition` which is the REPARTITION form only (ALTER +// TABLE t PARTITION BY ...) — that maps to omni's `ATPartitionBy`. +// Partition-management forms (ADD PARTITION, DROP PARTITION, etc.) +// are NOT covered by this rule in either era; long-standing +// pre-omni behavior preserved per invariant #10. Mysql analog +// (mysql/rule_table_disallow_partition.go) has identical scope. +func checkStmtForPartition(ostmt OmniStmt, level storepb.Advice_Status, title string) *storepb.Advice { + text := ostmt.TrimmedText() + switch n := ostmt.Node.(type) { case *ast.CreateTableStmt: - if node.Partition != nil { - code = advisorcode.CreateTablePartition + if n.Partitions != nil { + return buildPartitionAdvice(level, title, text, ostmt.FirstTokenLine()) } case *ast.AlterTableStmt: - for _, spec := range node.Specs { - if spec.Tp == ast.AlterTablePartition { - code = advisorcode.CreateTablePartition - break + for _, cmd := range n.Commands { + if cmd == nil { + continue + } + if cmd.Type == ast.ATPartitionBy && cmd.PartitionBy != nil { + return buildPartitionAdvice(level, title, text, ostmt.FirstTokenLine()) } } default: } - - if code != advisorcode.Ok { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.Int32(), - Title: checker.title, - Content: fmt.Sprintf("Table partition is forbidden, but \"%s\" creates", checker.text), - StartPosition: common.ConvertANTLRLineToPosition(checker.line), - }) - } - - return in, false + return nil } -// Leave implements the ast.Visitor interface. -func (*tableDisallowPartitionChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true +func buildPartitionAdvice(level storepb.Advice_Status, title, text string, line int) *storepb.Advice { + return &storepb.Advice{ + Status: level, + Code: advisorcode.CreateTablePartition.Int32(), + Title: title, + Content: fmt.Sprintf("Table partition is forbidden, but \"%s\" creates", text), + StartPosition: common.ConvertANTLRLineToPosition(line), + } } diff --git a/backend/plugin/advisor/tidb/test/column_maximum_character_length.yaml b/backend/plugin/advisor/tidb/test/column_maximum_character_length.yaml index d10a0562d56eb3..1d39bcba835732 100644 --- a/backend/plugin/advisor/tidb/test/column_maximum_character_length.yaml +++ b/backend/plugin/advisor/tidb/test/column_maximum_character_length.yaml @@ -81,3 +81,22 @@ line: 1 column: 0 endposition: null +# Cumulative #23 pin (batch 14, batch-13 follow-up): pingcap-tidb's +# pre-omni AlterTableAddColumns inner column loop had NO break — the +# LAST violating column in the grouped ADD COLUMN form overwrites and +# is reported. Asymmetric vs CreateTableStmt which DID break (first- +# wins). Initial batch-13 migration regressed to first-wins via early +# return; restored via lastViolation tracker. Same shape as cumulative +# #15 (charset_allowlist set-then-append cardinality) applied to a +# different call-site within the same advisor file. +- statement: ALTER TABLE tech_book ADD COLUMN (a char(225), b char(226)) + changeType: 1 + want: + - status: 2 + code: 415 + title: COLUMN_MAXIMUM_CHARACTER_LENGTH + content: The length of the CHAR column `b` is 226, bigger than 20, please use VARCHAR instead + startposition: + line: 1 + column: 0 + endposition: null diff --git a/backend/plugin/advisor/tidb/test/statement_insert_disallow_order_by_rand.yaml b/backend/plugin/advisor/tidb/test/statement_insert_disallow_order_by_rand.yaml index 3f0d54c72386f9..d41febcc4aeefa 100644 --- a/backend/plugin/advisor/tidb/test/statement_insert_disallow_order_by_rand.yaml +++ b/backend/plugin/advisor/tidb/test/statement_insert_disallow_order_by_rand.yaml @@ -11,3 +11,51 @@ line: 1 column: 0 endposition: null +# Batch 14: uppercase RAND positive — pingcap canonicalized to +# lowercase via FnName.L; omni preserves user case in FuncCallExpr.Name. +# omniIsRandFuncCall uses EqualFold to match both forms. +- statement: INSERT INTO tech_book SELECT * FROM tech_book ORDER BY RAND() + changeType: 1 + want: + - status: 2 + code: 1108 + title: STATEMENT_INSERT_DISALLOW_ORDER_BY_RAND + content: '"INSERT INTO tech_book SELECT * FROM tech_book ORDER BY RAND()" uses ORDER BY RAND in the INSERT statement' + startposition: + line: 1 + column: 0 + endposition: null +# Batch 14 negative: INSERT ... SELECT with ORDER BY on a column +# (not RAND) — the rule should NOT fire. Distinguishes "any ORDER BY" +# (would be a different rule) from "ORDER BY RAND() specifically". +- statement: INSERT INTO tech_book SELECT * FROM tech_book ORDER BY id + changeType: 1 +# Batch 14 negative: INSERT ... SET form (cumulative #1 territory) — +# ins.Select is nil; rule short-circuits before checking OrderBy. +# Pins the "SET-form has no SELECT to inspect" semantic. +- statement: INSERT INTO tech_book SET id = 1, name = "a" + changeType: 1 +# Cumulative #24 pin (batch 14, peer-review-caught pre-merge): pre-omni +# pingcap-tidb's InsertStmt.Select was a ResultSetNode interface; for +# UNION'd inserts the concrete type was *ast.SetOprStmt, NOT +# *ast.SelectStmt. The pingcap Enter's `insert.Select.(*ast.SelectStmt)` +# cast filtered out SetOprStmt entirely — pre-omni rule did NOT fire on +# `INSERT ... SELECT ... UNION SELECT ... ORDER BY RAND()` despite the +# rule's stated intent. Omni's InsertStmt.Select is *SelectStmt +# (concrete) regardless of SetOp; UNION'd inputs carry the outer ORDER +# BY at ins.Select.OrderBy. Rule now fires — silent UX improvement +# fixing a latent pingcap accidental skip. Same UX-improvement shape +# as cumulative #18 / #21. Inner per-arm parenthesized OrderBy remains +# uncovered (omni rejects parenthesized UNION arms in INSERT position +# today — separate Phase 2 grammar gap). +- statement: INSERT INTO tech_book SELECT id FROM tech_book UNION SELECT id FROM tech_book ORDER BY RAND() + changeType: 1 + want: + - status: 2 + code: 1108 + title: STATEMENT_INSERT_DISALLOW_ORDER_BY_RAND + content: '"INSERT INTO tech_book SELECT id FROM tech_book UNION SELECT id FROM tech_book ORDER BY RAND()" uses ORDER BY RAND in the INSERT statement' + startposition: + line: 1 + column: 0 + endposition: null diff --git a/backend/plugin/advisor/tidb/test/table_disallow_partition.yaml b/backend/plugin/advisor/tidb/test/table_disallow_partition.yaml index bc44903ea6dae9..a133d70e159ff6 100644 --- a/backend/plugin/advisor/tidb/test/table_disallow_partition.yaml +++ b/backend/plugin/advisor/tidb/test/table_disallow_partition.yaml @@ -38,3 +38,32 @@ line: 1 column: 0 endposition: null +# Batch 14: HASH partition positive — verifies the rule covers +# non-RANGE partition methods too. CreateTableStmt.Partitions is +# non-nil for any PARTITION BY ... clause regardless of method. +- statement: CREATE TABLE t(a int) PARTITION BY HASH(a) PARTITIONS 4 + changeType: 1 + want: + - status: 2 + code: 608 + title: TABLE_DISALLOW_PARTITION + content: 'Table partition is forbidden, but "CREATE TABLE t(a int) PARTITION BY HASH(a) PARTITIONS 4" creates' + startposition: + line: 1 + column: 0 + endposition: null +# Batch 14 negative: ALTER TABLE ... ADD PARTITION is a partition- +# management operation that the pre-omni pingcap rule did NOT cover — +# only `spec.Tp == AlterTablePartition` (the REPARTITION form, omni's +# ATPartitionBy) triggered. Mysql analog has the same scope. This +# fixture pins the long-standing behavior to prevent accidental +# scope expansion via mechanical edits. If a user reports the rule +# should also catch ADD PARTITION, it's a feature ticket, not a +# regression. +- statement: ALTER TABLE tech_book ADD PARTITION (PARTITION p2 VALUES LESS THAN (20)) + changeType: 1 +# Batch 14 negative: ALTER TABLE ... DROP PARTITION — same scope +# pin as ADD PARTITION above. Not caught by either pingcap-tidb or +# the omni port. +- statement: ALTER TABLE tech_book DROP PARTITION p0 + changeType: 1 diff --git a/backend/plugin/advisor/tidb/utils.go b/backend/plugin/advisor/tidb/utils.go index 98fdb0966c8be3..2a5a9addfaadef 100644 --- a/backend/plugin/advisor/tidb/utils.go +++ b/backend/plugin/advisor/tidb/utils.go @@ -410,6 +410,25 @@ func omniIsCurrentTimeFuncCall(expr omniast.ExprNode) bool { } } +// omniIsRandFuncCall checks whether the given expression is a +// function call to RAND(). Used by insert_disallow_order_by_rand. +// Pingcap-tidb compared via `FnName.L == ast.Rand` (lowercase +// canonicalization); omni keeps the user's original case in +// `FuncCallExpr.Name`, so we compare case-insensitively. RAND +// accepts an optional integer seed argument; we match regardless +// of arity (matching pingcap's predecessor — which type-asserted +// the function call but didn't inspect Args). +func omniIsRandFuncCall(expr omniast.ExprNode) bool { + if expr == nil { + return false + } + fc, ok := expr.(*omniast.FuncCallExpr) + if !ok { + return false + } + return strings.EqualFold(fc.Name, "RAND") +} + // omniIsCharOrBinaryType reports whether the column type is a // fixed-length character/binary type (CHAR or BINARY). Pingcap-tidb // dispatched on `mysql.TypeString` which covered BOTH `CHAR` and diff --git a/backend/plugin/advisor/tidb/utils_test.go b/backend/plugin/advisor/tidb/utils_test.go index 91d00f47e66c2a..0ce446ecdb5dee 100644 --- a/backend/plugin/advisor/tidb/utils_test.go +++ b/backend/plugin/advisor/tidb/utils_test.go @@ -344,6 +344,33 @@ func TestOmniIsCurrentTimeFuncCall(t *testing.T) { } } +// TestOmniIsRandFuncCall pins the case-insensitive RAND function +// detection contract used by insert_disallow_order_by_rand. Pingcap-tidb +// matched via lowercase-canonical `FnName.L == ast.Rand`; omni preserves +// the user's case in `FuncCallExpr.Name`, so we compare via EqualFold. +// RAND accepts an optional seed arg — we match regardless of arity. +func TestOmniIsRandFuncCall(t *testing.T) { + cases := []struct { + name string + expr omniast.ExprNode + want bool + }{ + {"RAND uppercase", &omniast.FuncCallExpr{Name: "RAND"}, true}, + {"rand lowercase", &omniast.FuncCallExpr{Name: "rand"}, true}, + {"Rand titlecase", &omniast.FuncCallExpr{Name: "Rand"}, true}, + {"RAND with seed arg", &omniast.FuncCallExpr{Name: "RAND", Args: []omniast.ExprNode{nil}}, true}, + {"RANDOM (not synonym)", &omniast.FuncCallExpr{Name: "RANDOM"}, false}, + {"NOT_RAND prefix-like", &omniast.FuncCallExpr{Name: "NOT_RAND"}, false}, + {"unknown function", &omniast.FuncCallExpr{Name: "FOO"}, false}, + {"nil expr", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, omniIsRandFuncCall(tc.expr)) + }) + } +} + // TestOmniIsCharOrBinaryType pins cumulative #22 — pingcap's // `mysql.TypeString` covered BOTH CHAR and BINARY via charset // distinction. The omni port must match both names; a mechanical From 16864da5344696e2ab83d47dde66c63130959eca Mon Sep 17 00:00:00 2001 From: p0ny Date: Tue, 12 May 2026 17:17:05 +0800 Subject: [PATCH 023/127] fix: preserve task run cancellation error (#20319) --- backend/runner/taskrun/database_migrate_executor.go | 2 +- .../runner/taskrun/database_migrate_executor_test.go | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/runner/taskrun/database_migrate_executor.go b/backend/runner/taskrun/database_migrate_executor.go index a228b5dda94051..f833b618df5a75 100644 --- a/backend/runner/taskrun/database_migrate_executor.go +++ b/backend/runner/taskrun/database_migrate_executor.go @@ -374,7 +374,7 @@ func executeGhostMigration(ctx context.Context, driverCtx context.Context, task opts.LogGhostMigrationEnd("") return nil case <-driverCtx.Done(): - err := errors.New("task canceled") + err := errors.Wrap(driverCtx.Err(), "task canceled") opts.LogGhostMigrationEnd(err.Error()) abortCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() diff --git a/backend/runner/taskrun/database_migrate_executor_test.go b/backend/runner/taskrun/database_migrate_executor_test.go index e71713f6942ebf..cb1c65b12d5515 100644 --- a/backend/runner/taskrun/database_migrate_executor_test.go +++ b/backend/runner/taskrun/database_migrate_executor_test.go @@ -1,13 +1,25 @@ package taskrun import ( + "context" "testing" + "github.com/pkg/errors" "github.com/stretchr/testify/require" storepb "github.com/bytebase/bytebase/backend/generated-go/store" ) +func TestWrappedContextCanceledMatchesErrorsIs(t *testing.T) { + driverCtx, cancel := context.WithCancel(context.Background()) + cancel() + + err := errors.Wrap(driverCtx.Err(), "task canceled") + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + require.Contains(t, err.Error(), "task canceled") +} + func TestGetPrependStatements(t *testing.T) { tests := []struct { name string From 1811b7e193f8a82e0df1a98e55561f4e3de53fda Mon Sep 17 00:00:00 2001 From: Vincent Huang <40749774+vsai12@users.noreply.github.com> Date: Tue, 12 May 2026 02:54:50 -0700 Subject: [PATCH 024/127] =?UTF-8?q?refactor(advisor/tidb):=20migrate=20sta?= =?UTF-8?q?tement=20family=20pair=20to=20omni=20AST=20(Phase=201.5=20?= =?UTF-8?q?=C2=A71.5.3=20batch=2015)=20(#20320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(advisor/tidb): migrate statement family pair to omni AST (Phase 1.5 §1.5.3 batch 15) Two statement-family advisors, complementary recipes: - advisor_statement_merge_alter_table (Recipe A — cross-stmt aggregator): Build per-table {count, lastLine} across statements; sort by lastLine; emit one advice per table with count>1. CREATE TABLE counts as 1 modification on that table (preserved from pingcap- tidb's pre-omni shape — fixture-pinned). No sub-walks. - advisor_statement_maximum_limit_value (Recipe B — ast.Walk): Walks every nested SelectStmt; fires on Limit.Count > maximum. Preserves pingcap-tidb's Accept-based recursion semantics via ast.Walk's coverage of SelectStmt.{CTEs, TargetList, From, Where, GroupBy, Having, OrderBy, Limit, Left, Right} (verified empirically against omni walk_generated.go:720). First batch-author reference for Recipe B since batch 5; subsequent advisors with sub-walks (prior_backup_check etc.) follow this template. Scope preservation per invariant #7: - maximum_limit_value checks Limit.Count only (NOT Offset). Mysql analog checks both; tidb preserves narrower pingcap-tidb scope. - maximum_limit_value uses strict-greater (>, not >=). Fixture-pinned. - All advices use the top-level statement's first-token line, even for fires deep inside subqueries (mirrors pre-omni's `checker.line = stmt.OriginTextPosition()` set-once semantic). Empirical shape verification (Limit.Count → *ast.IntLit{Value int64}; Walk recursion into FROM/Where subqueries + UNION arms) captured in fixture-pin commentary. Migration count: 39 → 41 omni-migrated; 8 remaining (7 migratable + 1 Class III blocked + utils.go bridge). Co-Authored-By: Claude Opus 4.7 (1M context) * test(advisor/tidb): expand fixture pins for statement family pair (Phase 1.5 §1.5.3 batch 15) statement_maximum_limit_value.yaml — six new pins: - Nested subquery in FROM (exercises Walk recursion into From) - UNION-arm LIMIT (exercises Walk recursion into Left/Right) - WHERE-IN subquery (exercises Walk recursion into Where expressions) - LIMIT exactly at maximum (strict-greater contract) - No LIMIT clause (baseline negative) - LIMIT offset, count form (Count field semantics — count is the second number, not the first; pinned empirically) statement_merge_alter_table.yaml — two new pins: - Count=3 (counter grows past 2) - Per-table isolation (two ALTERs on different tables do NOT trigger) Co-Authored-By: Claude Opus 4.7 (1M context) * fix(advisor/tidb): preserve CREATE TABLE reset semantics on statement_merge_alter_table (cumulative #25) Pingcap-tidb's pre-omni statement_merge_alter_table Enter method handled CREATE and ALTER arms with DIFFERENT mutation semantics on the cross-statement tableMap: - CREATE arm: `tableMap[name] = tableStatement{count: 1, lastLine}` (REPLACE — resets the per-table state) - ALTER arm: read-modify-write with count++ (INCREMENT — accumulates) The reset is the rule's "different table incarnations cannot be merged" semantic. For `CREATE t; ALTER t; CREATE t; ALTER t` the pre-omni rule reported "There are 2 statements to modify table t" (second CREATE resets count to 1, trailing ALTER bumps to 2). Initial batch 15 migration unified both arms behind a single touch(name, line) helper that always incremented. Same input would have reported "There are 4 statements" — false count merging modifications across re-incarnations. Caught pre-merge by Codex P2. Fix: split the CREATE arm out of the shared touchAlter helper. CREATE writes a fresh tableState{count: 1, lastLine}; ALTER continues using touchAlter (increment). Pinned with `CREATE t; ALTER t; CREATE t; ALTER t` fixture asserting "There are 2 statements" (not 4). Existing fixtures didn't exercise the reset path because count=2 under both semantics (only one CREATE per table). NEW pre-batch audit axis added to plan-doc cumulative #25: state-mutation semantics per arm. When migrating a Visitor that mutates cross-statement state, enumerate per-arm semantics (REPLACE vs INCREMENT vs DELETE) and split arms accordingly. Don't unify behind a single setter unless verified-identical. Three audit axes now in the framework: loop-control symmetry (#23), type-assertion narrowing (#24), state-mutation semantics per arm (#25). Co-Authored-By: Claude Opus 4.7 (1M context) * test(advisor/tidb): pin cumulative #26 — UNION outer-LIMIT UX improvement on statement_maximum_limit_value Codex P2 caught a silent behavior change at the UNION-root boundary that batch 15's initial PR description elided. Pre-omni pingcap represents `SELECT a FROM s UNION SELECT b FROM t LIMIT N` (LIMIT without parens — MySQL grammar attaches to the OUTER UNION result) as `*ast.SetOprStmt{Limit: ...}`. The outer LIMIT lives on the SetOprStmt itself; both UNION-arm SelectStmts have nil Limit. The pre-omni rule's Enter matched only `*ast.SelectStmt` — never `*ast.SetOprStmt` — so the outer LIMIT was silently skipped. Omni unifies UNION-root under `*ast.SelectStmt{SetOp: Union, Left, Right, Limit}` — same struct as plain SELECT with set-op metadata. ast.Walk visits the outer SelectStmt and reads the outer-UNION Limit via the same `sel.Limit.Count.(*ast.IntLit)` path used for plain SELECT. Rule now fires on outer-UNION LIMIT — silent UX improvement matching rule intent. Verified empirically by parsing the same input through both pingcap and omni parsers: Input: SELECT * FROM employee UNION SELECT * FROM employee LIMIT 1001 pingcap: root = *ast.SetOprStmt with Limit=1001; arms have nil Limit omni: 3 SelectStmt visits via Walk; visits[0] SetOp=Union Limit.Count=IntLit(1001) Same structural shape as cumulative #24 (UNION outer-ORDER-BY on insert_disallow_order_by_rand). Second occurrence of the same axis across consecutive batches (14, 15) — UNION-root unification is promoted from isolated case to recurring meta-pattern in plan-doc. Updates: - Advisor comment removes "equivalent traversal coverage" claim (technically false at UNION-root boundary) and explicitly documents the UNION outer-LIMIT new coverage with the pre-omni/post-omni divergence - New positive fixture: SELECT * FROM employee UNION SELECT * FROM employee LIMIT 1001 fires with "limit value 1001 exceeds 1000" - Plan-doc cumulative #26 added with empirical receipts + cross- reference to #24 - Retroactive audit of remaining 6 migratable advisors: zero narrow-cast patterns; prior_backup_check has explicit *ast.SetOprStmt nil-return arm (no implicit drift when migrated) - UNION-root audit axis added to pre-batch protocol alongside #23 (loop-control symmetry), #24 (type-assertion narrowing), and #25 (state-mutation semantics per arm) Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../advisor_statement_maximum_limit_value.go | 148 +++++++++++------ .../advisor_statement_merge_alter_table.go | 156 ++++++++---------- .../test/statement_maximum_limit_value.yaml | 96 +++++++++++ .../test/statement_merge_alter_table.yaml | 46 ++++++ 4 files changed, 308 insertions(+), 138 deletions(-) diff --git a/backend/plugin/advisor/tidb/advisor_statement_maximum_limit_value.go b/backend/plugin/advisor/tidb/advisor_statement_maximum_limit_value.go index a1b588cd766cf7..c58f9d5982826a 100644 --- a/backend/plugin/advisor/tidb/advisor_statement_maximum_limit_value.go +++ b/backend/plugin/advisor/tidb/advisor_statement_maximum_limit_value.go @@ -1,13 +1,10 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" - "github.com/pingcap/tidb/pkg/parser/ast" - driver "github.com/pingcap/tidb/pkg/types/parser_driver" + "github.com/bytebase/omni/tidb/ast" "github.com/pkg/errors" "github.com/bytebase/bytebase/backend/common" @@ -18,21 +15,59 @@ import ( var ( _ advisor.Advisor = (*StatementMaximumLimitValueAdvisor)(nil) - _ ast.Visitor = (*statementMaximumLimitValueChecker)(nil) + _ ast.Visitor = (*maxLimitChecker)(nil) ) func init() { advisor.Register(storepb.Engine_TIDB, storepb.SQLReviewRule_STATEMENT_MAXIMUM_LIMIT_VALUE, &StatementMaximumLimitValueAdvisor{}) } -// StatementMaximumLimitValueAdvisor is the advisor checking for LIMIT maximum value. -type StatementMaximumLimitValueAdvisor struct { -} - -// Check checks for LIMIT maximum value in SELECT statements. +// StatementMaximumLimitValueAdvisor flags SELECT statements whose LIMIT +// count exceeds the configured maximum. +type StatementMaximumLimitValueAdvisor struct{} + +// Check fires on every SelectStmt (top-level and nested) with +// Limit.Count > maximum. Recipe B (ast.Walk) recursion covers: +// subqueries in FROM, WHERE IN clauses, UNION arms, CTEs — matching +// pingcap-tidb's Accept-based traversal at the inner-SelectStmt +// boundaries. Omni's `ast.Walk` recurses into +// SelectStmt.{CTEs, TargetList, From, Where, GroupBy, Having, +// OrderBy, Limit, Left, Right} per walk_generated.go:720. +// +// Cumulative #26 — silent UX improvement at the UNION-root boundary: +// pingcap represents `SELECT ... UNION SELECT ... LIMIT n` (LIMIT +// without parens — attaches to the OUTER UNION result) as +// `*ast.SetOprStmt{Limit: ...}` with the UNION arms as inner +// `*ast.SelectStmt`s with nil Limits. The pre-omni rule's Enter +// matched only `*ast.SelectStmt` so the outer LIMIT lived on a +// concrete type the rule never inspected — silently skipped. +// Omni unifies UNION-root under `*ast.SelectStmt{SetOp: !=None, +// Limit: ...}` (same struct, set-op metadata), so the Walk visits +// the outer SelectStmt and reads the outer-UNION Limit directly. +// Rule now fires on the outer-UNION LIMIT case. Same structural +// shape as cumulative #24 (UNION outer-ORDER-BY on +// insert_disallow_order_by_rand). NOT a regression — pre-omni miss +// was an accidental artifact of pingcap's distinct SetOprStmt type +// being filtered out by the rule's narrow type-assert, not the +// rule's intent. +// +// Scope preservation per invariant #7: +// - Only `Limit.Count` is checked. `Limit.Offset` is NOT (mysql +// analog also checks Offset; tidb-omni preserves the narrower +// pingcap-tidb scope). +// - Non-IntLit Count values (expressions, placeholders if/when +// omni grammar accepts them) are silently skipped — matches the +// pre-omni `_, ok := node.Limit.Count.(*driver.ValueExpr)` cast +// which would also have failed for non-literal counts. +// - Strict-greater (`>`, not `>=`) — preserved. +// - Every advice (including those fired on nested SelectStmts in +// subqueries OR on UNION-root outer Limits) uses the TOP-LEVEL +// statement's first-token line. Pre-omni rule wrote +// `checker.line = stmt.OriginTextPosition()` once per top-level +// and reused it for every advice it emitted during that +// statement's walk. func (*StatementMaximumLimitValueAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -45,52 +80,57 @@ func (*StatementMaximumLimitValueAdvisor) Check(_ context.Context, checkCtx advi if numberPayload == nil { return nil, errors.New("number_payload is required for maximum limit value rule") } - - checker := &statementMaximumLimitValueChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - limitMaxValue: int(numberPayload.Number), - } - - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + maximum := int64(numberPayload.Number) + title := checkCtx.Rule.Type.String() + + var adviceList []*storepb.Advice + for _, ostmt := range stmts { + c := &maxLimitChecker{ + level: level, + title: title, + maximum: maximum, + line: ostmt.FirstTokenLine(), + } + ast.Walk(c, ostmt.Node) + adviceList = append(adviceList, c.advices...) } - - return checker.adviceList, nil + return adviceList, nil } -type statementMaximumLimitValueChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int - limitMaxValue int +type maxLimitChecker struct { + level storepb.Advice_Status + title string + maximum int64 + line int + advices []*storepb.Advice } -// Enter implements the ast.Visitor interface. -func (checker *statementMaximumLimitValueChecker) Enter(in ast.Node) (ast.Node, bool) { - node, ok := in.(*ast.SelectStmt) - if ok && node.Limit != nil { - if ve, ok := node.Limit.Count.(*driver.ValueExpr); ok { - limitVal := ve.GetInt64() - if limitVal > int64(checker.limitMaxValue) { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.StatementExceedMaximumLimitValue.Int32(), - Title: checker.title, - Content: fmt.Sprintf("The limit value %d exceeds the maximum allowed value %d", limitVal, checker.limitMaxValue), - StartPosition: common.ConvertANTLRLineToPosition(checker.line), - }) - } - } +// Visit implements ast.Visitor. Returns self to continue recursion; +// `Visit(nil)` is the post-order signal — we handle it with an early +// return at the top of the method. +func (c *maxLimitChecker) Visit(n ast.Node) ast.Visitor { + if n == nil { + return c } - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*statementMaximumLimitValueChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true + sel, ok := n.(*ast.SelectStmt) + if !ok { + return c + } + if sel.Limit == nil || sel.Limit.Count == nil { + return c + } + lit, ok := sel.Limit.Count.(*ast.IntLit) + if !ok { + return c + } + if lit.Value > c.maximum { + c.advices = append(c.advices, &storepb.Advice{ + Status: c.level, + Code: code.StatementExceedMaximumLimitValue.Int32(), + Title: c.title, + Content: fmt.Sprintf("The limit value %d exceeds the maximum allowed value %d", lit.Value, c.maximum), + StartPosition: common.ConvertANTLRLineToPosition(c.line), + }) + } + return c } diff --git a/backend/plugin/advisor/tidb/advisor_statement_merge_alter_table.go b/backend/plugin/advisor/tidb/advisor_statement_merge_alter_table.go index fd4856bf49e98b..f33a443b8bd6e8 100644 --- a/backend/plugin/advisor/tidb/advisor_statement_merge_alter_table.go +++ b/backend/plugin/advisor/tidb/advisor_statement_merge_alter_table.go @@ -1,13 +1,11 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" "slices" - "github.com/pingcap/tidb/pkg/parser/ast" + "github.com/bytebase/omni/tidb/ast" "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" @@ -17,21 +15,25 @@ import ( var ( _ advisor.Advisor = (*StatementMergeAlterTableAdvisor)(nil) - _ ast.Visitor = (*statementMergeAlterTableChecker)(nil) ) func init() { advisor.Register(storepb.Engine_TIDB, storepb.SQLReviewRule_STATEMENT_MERGE_ALTER_TABLE, &StatementMergeAlterTableAdvisor{}) } -// StatementMergeAlterTableAdvisor is the advisor checking for merging ALTER TABLE statements. -type StatementMergeAlterTableAdvisor struct { -} - -// Check checks for merging ALTER TABLE statements. +// StatementMergeAlterTableAdvisor flags multiple ALTER TABLE statements +// on the same table that could be merged into one. The pre-omni rule +// accumulated per-table {count, lastLine} across statements, sorted +// tables by lastLine, and emitted one advice per table with count>1. +// Pure aggregator pattern (Recipe A); no sub-walks. Preserves pingcap- +// tidb's "CREATE TABLE counts as 1 modification on that table" framing +// per fixture line 16-30: CREATE on t followed by 1 ALTER on t emits +// the same "2 statements to modify table" advice as 2 ALTERs. +type StatementMergeAlterTableAdvisor struct{} + +// Check flags tables modified more than once across the reviewed statements. func (*StatementMergeAlterTableAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -40,94 +42,80 @@ func (*StatementMergeAlterTableAdvisor) Check(_ context.Context, checkCtx adviso if err != nil { return nil, err } - checker := &statementMergeAlterTableChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - tableMap: make(map[string]tableStatement), - } + title := checkCtx.Rule.Type.String() - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + type tableState struct { + name string + count int + lastLine int } - - return checker.generateAdvice(), nil -} - -type statementMergeAlterTableChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int - tableMap map[string]tableStatement -} - -type tableStatement struct { - name string - count int - lastLine int -} - -// Enter implements the ast.Visitor interface. -func (checker *statementMergeAlterTableChecker) Enter(in ast.Node) (ast.Node, bool) { - switch node := in.(type) { - case *ast.CreateTableStmt: - data := tableStatement{ - name: node.Table.Name.O, - count: 1, - lastLine: checker.line, + tableMap := make(map[string]*tableState) + // touchAlter INCREMENTS the per-table {count, lastLine}. Only ALTER + // uses this path — CREATE has reset semantics (see below). + touchAlter := func(name string, line int) { + entry := tableMap[name] + if entry == nil { + entry = &tableState{name: name} + tableMap[name] = entry } - checker.tableMap[node.Table.Name.O] = data - case *ast.AlterTableStmt: - data, ok := checker.tableMap[node.Table.Name.O] - if !ok { - data = tableStatement{ - name: node.Table.Name.O, - count: 0, + entry.count++ + entry.lastLine = line + } + + for _, ostmt := range stmts { + switch n := ostmt.Node.(type) { + case *ast.CreateTableStmt: + // Cumulative #25: CREATE TABLE RESETS the per-table state to + // {count: 1, lastLine}, mirroring pre-omni semantics. A second + // CREATE on the same name (e.g. after a DROP) starts a fresh + // window of modifications rather than carrying over the prior + // count — otherwise `CREATE t; ALTER t; CREATE t; ALTER t` + // would report "4 statements" instead of "2", merging + // modifications across table incarnations that cannot + // actually be merged. The pre-omni rule wrote the map entry + // unconditionally; mechanical port via a single touch() + // helper loses the reset semantic. + if n.Table != nil { + tableMap[n.Table.Name] = &tableState{ + name: n.Table.Name, + count: 1, + lastLine: ostmt.FirstTokenLine(), + } } + case *ast.AlterTableStmt: + if n.Table != nil { + touchAlter(n.Table.Name, ostmt.FirstTokenLine()) + } + default: } - data.count++ - data.lastLine = checker.line - checker.tableMap[node.Table.Name.O] = data - default: } - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*statementMergeAlterTableChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true -} - -func (checker *statementMergeAlterTableChecker) generateAdvice() []*storepb.Advice { - var tableList []tableStatement - for _, table := range checker.tableMap { - tableList = append(tableList, table) + tableList := make([]*tableState, 0, len(tableMap)) + for _, t := range tableMap { + tableList = append(tableList, t) } - slices.SortFunc(tableList, func(i, j tableStatement) int { - if i.lastLine < j.lastLine { + slices.SortFunc(tableList, func(i, j *tableState) int { + switch { + case i.lastLine < j.lastLine: return -1 - } - if i.lastLine > j.lastLine { + case i.lastLine > j.lastLine: return 1 + default: + return 0 } - return 0 }) - for _, table := range tableList { - if table.count > 1 { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, + var adviceList []*storepb.Advice + for _, t := range tableList { + if t.count > 1 { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, Code: code.StatementRedundantAlterTable.Int32(), - Title: checker.title, - Content: fmt.Sprintf("There are %d statements to modify table `%s`", table.count, table.name), - StartPosition: common.ConvertANTLRLineToPosition(table.lastLine), + Title: title, + Content: fmt.Sprintf("There are %d statements to modify table `%s`", t.count, t.name), + StartPosition: common.ConvertANTLRLineToPosition(t.lastLine), }) } } - - return checker.adviceList + return adviceList, nil } diff --git a/backend/plugin/advisor/tidb/test/statement_maximum_limit_value.yaml b/backend/plugin/advisor/tidb/test/statement_maximum_limit_value.yaml index ec1bc9b6866838..a2cc65c97e2cf8 100644 --- a/backend/plugin/advisor/tidb/test/statement_maximum_limit_value.yaml +++ b/backend/plugin/advisor/tidb/test/statement_maximum_limit_value.yaml @@ -10,3 +10,99 @@ line: 1 column: 0 endposition: null +# Batch 15: nested-subquery in FROM — exercises ast.Walk recursion into +# SelectStmt.From. Pre-omni pingcap's Accept(checker) auto-recursed into +# subqueries via the same path; omni's Walk recurses via walk_generated +# (verified empirically). Both the outer SELECT and the inner subquery +# get visited; the inner has LIMIT 10000 > max=1000 → fires. +- statement: SELECT * FROM (SELECT * FROM employee LIMIT 10000) AS sub + changeType: 1 + want: + - status: 2 + code: 222 + title: STATEMENT_MAXIMUM_LIMIT_VALUE + content: The limit value 10000 exceeds the maximum allowed value 1000 + startposition: + line: 1 + column: 0 + endposition: null +# Batch 15: UNION-arm LIMIT — exercises ast.Walk recursion into +# SelectStmt.Left / SelectStmt.Right (the UNION arms). Pingcap-tidb +# Accept-traversal visited both arms; omni Walk does likewise. The left +# arm has LIMIT 10000 → fires. +- statement: SELECT a FROM employee LIMIT 10000 UNION SELECT b FROM employee + changeType: 1 + want: + - status: 2 + code: 222 + title: STATEMENT_MAXIMUM_LIMIT_VALUE + content: The limit value 10000 exceeds the maximum allowed value 1000 + startposition: + line: 1 + column: 0 + endposition: null +# Batch 15: WHERE-IN subquery — exercises Walk recursion into WHERE +# expressions and their nested SelectStmts. Pingcap Accept covered this +# via interface dispatch on Expr.Accept; omni Walk does via expression- +# tree recursion. +- statement: SELECT * FROM employee WHERE id IN (SELECT id FROM employee LIMIT 10000) + changeType: 1 + want: + - status: 2 + code: 222 + title: STATEMENT_MAXIMUM_LIMIT_VALUE + content: The limit value 10000 exceeds the maximum allowed value 1000 + startposition: + line: 1 + column: 0 + endposition: null +# Batch 15: strict-greater pin — LIMIT exactly at maximum (1000) does +# NOT fire. Preserves pingcap-tidb's `limitVal > int64(...)` strict +# inequality. Without this pin, a future refactor to `>=` would silently +# break customer workflows that set the limit AT the threshold. +- statement: SELECT * FROM employee LIMIT 1000 + changeType: 1 +# Batch 15: no LIMIT — does NOT fire (baseline negative). +- statement: SELECT * FROM employee + changeType: 1 +# Batch 15: LIMIT offset, count form — pingcap-tidb's pre-omni rule +# accessed `node.Limit.Count` which for `LIMIT 50, 10000` is the COUNT +# (10000), NOT the offset (50). Omni's `Limit.Count` field is also the +# count (verified empirically: parse `LIMIT 50, 10000` → Count IntLit +# Value=10000). Rule fires on count=10000 > 1000. +- statement: SELECT * FROM employee LIMIT 50, 10000 + changeType: 1 + want: + - status: 2 + code: 222 + title: STATEMENT_MAXIMUM_LIMIT_VALUE + content: The limit value 10000 exceeds the maximum allowed value 1000 + startposition: + line: 1 + column: 0 + endposition: null +# Cumulative #26 pin (batch 15, Codex P2 catch pre-merge): pre-omni +# pingcap represented `SELECT ... UNION SELECT ... LIMIT N` as +# `*ast.SetOprStmt{Limit: ...}` — distinct concrete type with the +# outer LIMIT on the SetOprStmt itself; inner SelectStmt arms have +# nil Limit. The pre-omni rule's Enter matched only `*ast.SelectStmt` +# so this outer LIMIT was silently skipped — pre-omni did NOT fire +# despite the rule's stated intent ("limit value exceeds maximum"). +# Omni unifies UNION-root under `*ast.SelectStmt{SetOp:Union, Limit: +# ...}` (same struct, set-op metadata); ast.Walk visits the outer +# SelectStmt and reads the outer-UNION Limit. Rule now fires — +# silent UX improvement matching rule intent. Same structural shape +# as cumulative #24 (UNION outer-ORDER-BY on +# insert_disallow_order_by_rand) — second occurrence of UNION-root +# unification as a divergence axis across consecutive batches (14/15). +- statement: SELECT * FROM employee UNION SELECT * FROM employee LIMIT 1001 + changeType: 1 + want: + - status: 2 + code: 222 + title: STATEMENT_MAXIMUM_LIMIT_VALUE + content: The limit value 1001 exceeds the maximum allowed value 1000 + startposition: + line: 1 + column: 0 + endposition: null diff --git a/backend/plugin/advisor/tidb/test/statement_merge_alter_table.yaml b/backend/plugin/advisor/tidb/test/statement_merge_alter_table.yaml index bb7394e8f86926..52cd7628fc9c85 100644 --- a/backend/plugin/advisor/tidb/test/statement_merge_alter_table.yaml +++ b/backend/plugin/advisor/tidb/test/statement_merge_alter_table.yaml @@ -59,3 +59,49 @@ line: 4 column: 0 endposition: null +# Batch 15: count=3 pin — verifies the count grows past 2. Three +# touches on the same table emit "There are 3 statements" not just "2". +# Lock the count-accuracy contract. +- statement: |- + ALTER TABLE tech_book ADD COLUMN a int; + ALTER TABLE tech_book ADD COLUMN b int; + ALTER TABLE tech_book ADD COLUMN c int; + changeType: 1 + want: + - status: 2 + code: 207 + title: STATEMENT_MERGE_ALTER_TABLE + content: There are 3 statements to modify table `tech_book` + startposition: + line: 3 + column: 0 + endposition: null +# Batch 15: per-table isolation pin — two ALTERs on DIFFERENT tables +# do NOT trigger. Each table's count is 1 individually, neither > 1. +# Pins the per-table-keyed counting (vs accidental cross-table count). +- statement: |- + ALTER TABLE a ADD COLUMN x int; + ALTER TABLE b ADD COLUMN y int; + changeType: 1 +# Cumulative #25 pin (batch 15, Codex P2 catch pre-merge): CREATE TABLE +# RESETS the per-table state to {count: 1, lastLine}, mirroring pre-omni +# semantics. A second CREATE on the same name starts a fresh window of +# modifications rather than carrying over the prior count. Initial +# batch 15 migration unified CREATE + ALTER behind a single touch() +# helper that always incremented — would have reported "4 statements +# to modify table `t`" for this input instead of "2". +- statement: |- + CREATE TABLE t(a int); + ALTER TABLE t ADD COLUMN a int; + CREATE TABLE t(b int); + ALTER TABLE t ADD COLUMN c int; + changeType: 1 + want: + - status: 2 + code: 207 + title: STATEMENT_MERGE_ALTER_TABLE + content: There are 2 statements to modify table `t` + startposition: + line: 4 + column: 0 + endposition: null From 107635b4c623722bd7ad156851bbd069ce8204bb Mon Sep 17 00:00:00 2001 From: boojack Date: Tue, 12 May 2026 18:09:33 +0800 Subject: [PATCH 025/127] =?UTF-8?q?chore(frontend):=20drop=2021=20orphan?= =?UTF-8?q?=20Vue=20primitives=20+=20add=20React=E2=86=92Vue=20import=20gu?= =?UTF-8?q?ard=20(#20321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(frontend): add CI guard blocking React-layer imports of .vue files Mount-bridges (SessionExpiredSurfaceMount, AgentWindowMount) are allowlisted until Phase B retires the Vue app shell. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(frontend): drop orphan Vue files with zero callers Removes 9 .vue files whose call sites already migrated to React or were never used. Empty parent directories cleaned up. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(frontend): drop unused v2/TabFilter TabFilter had no callers outside its own re-export plumbing in v2/index.ts. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(frontend): drop orphan FeatureAttention.vue React callers already use @/react/components/FeatureAttention. FeatureBadge/FeatureModal stay — Vue callers remain. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(frontend): drop unused SQLReview RuleConfigComponents Vue files The 5 *.vue component files had no callers. Kept types.ts and utils.ts — still imported by the React rule-components and a Vue-side sibling utility. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(frontend): drop Vue AdvancedSearch components All consumers now use @/react/components/AdvancedSearch. types.ts kept (still imported by utils/accessGrant.ts). useCommonSearchScopeOptions.ts is now orphaned but left in place — flagged for a follow-up cleanup PR. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(frontend): drop orphaned advanced-search locale keys Removed by sort:i18n after the Vue AdvancedSearch components were deleted. The React AdvancedSearch.tsx uses its own keys (issue.advanced-search.*). Co-Authored-By: Claude Opus 4.7 (1M context) * docs(react-migration): Phase A spec, plan, and updated status - 2026-05-12-react-migration-status-and-plan.md: refresh status doc. SQL Editor is now ✅ complete (223 React files, 0 Vue UI files); former Phase A (SQL Editor) folds into Phase B shell retirement. Counts refreshed (.tsx 681, ui primitives 46, etc.). - 2026-05-12-phase-a-legacy-primitives-design.md: design for the cross-framework cleanup — eliminate all React→Vue imports. - 2026-05-12-phase-a-legacy-primitives-plan.md: bite-sized implementation plan that this PR executes. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(frontend): drop 3 more orphan files missed by Phase A Dead-sweep pass found three orphan files the initial Phase A scan missed: - FileContentPreviewModal.vue — zero importers; the one match in sql-editor/StandardPanel/SQLUploadButton.tsx is an import of the React sibling (./FileContentPreviewModal), not the Vue file. - Permission/PermissionGuardWrapper.vue — zero importers. Permission/ directory removed (now empty). - AdvancedSearch/useCommonSearchScopeOptions.ts — orphaned in the previous AdvancedSearch deletion commit (flagged for follow-up at the time); React port at @/react/components/useCommonSearchScopeOptions is the only caller. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- ...-05-12-phase-a-legacy-primitives-design.md | 111 +++ ...26-05-12-phase-a-legacy-primitives-plan.md | 568 ++++++++++++++ ...6-05-12-react-migration-status-and-plan.md | 152 ++++ .../AdvancedSearch/AdvancedSearch.vue | 734 ------------------ .../components/AdvancedSearch/ScopeMenu.vue | 97 --- .../components/AdvancedSearch/ScopeTags.vue | 82 -- .../components/AdvancedSearch/TimeRange.vue | 96 --- .../components/AdvancedSearch/ValueMenu.vue | 159 ---- .../src/components/AdvancedSearch/index.ts | 7 - .../useCommonSearchScopeOptions.ts | 219 ------ .../DatabaseDetail/SyncDatabaseButton.vue | 88 --- frontend/src/components/DatabaseInfo.vue | 83 -- .../src/components/EditEnvironmentDrawer.vue | 58 -- .../FeatureGuard/FeatureAttention.vue | 167 ---- frontend/src/components/FeatureGuard/index.ts | 3 +- .../components/FileContentPreviewModal.vue | 100 --- .../Instance/InstanceSyncButton.vue | 204 ----- .../Permission/NoPermissionPlaceholder.vue | 51 -- .../Permission/PermissionGuardWrapper.vue | 39 - frontend/src/components/RequiredStar.vue | 3 - .../RoleGrantPanel/MaxRowCountSelect.vue | 91 --- .../RuleConfigComponents/BooleanComponent.vue | 37 - .../RuleConfigComponents/NumberComponent.vue | 23 - .../StringArrayComponent.vue | 39 - .../RuleConfigComponents/StringComponent.vue | 23 - .../TemplateComponent.vue | 45 -- .../components/RuleConfigComponents/index.ts | 13 - frontend/src/components/misc/MaskSpinner.vue | 23 - .../src/components/misc/SQLUploadButton.vue | 98 --- .../src/components/v2/TabFilter/TabFilter.vue | 74 -- frontend/src/components/v2/TabFilter/index.ts | 4 - frontend/src/components/v2/TabFilter/types.ts | 4 - frontend/src/components/v2/index.ts | 1 - frontend/src/locales/en-US.json | 7 +- frontend/src/locales/es-ES.json | 7 +- frontend/src/locales/ja-JP.json | 7 +- frontend/src/locales/vi-VN.json | 7 +- frontend/src/locales/zh-CN.json | 7 +- .../src/react/no-react-to-vue-imports.test.ts | 43 + 39 files changed, 885 insertions(+), 2689 deletions(-) create mode 100644 docs/plans/2026-05-12-phase-a-legacy-primitives-design.md create mode 100644 docs/plans/2026-05-12-phase-a-legacy-primitives-plan.md create mode 100644 docs/plans/2026-05-12-react-migration-status-and-plan.md delete mode 100644 frontend/src/components/AdvancedSearch/AdvancedSearch.vue delete mode 100644 frontend/src/components/AdvancedSearch/ScopeMenu.vue delete mode 100644 frontend/src/components/AdvancedSearch/ScopeTags.vue delete mode 100644 frontend/src/components/AdvancedSearch/TimeRange.vue delete mode 100644 frontend/src/components/AdvancedSearch/ValueMenu.vue delete mode 100644 frontend/src/components/AdvancedSearch/index.ts delete mode 100644 frontend/src/components/AdvancedSearch/useCommonSearchScopeOptions.ts delete mode 100644 frontend/src/components/DatabaseDetail/SyncDatabaseButton.vue delete mode 100644 frontend/src/components/DatabaseInfo.vue delete mode 100644 frontend/src/components/EditEnvironmentDrawer.vue delete mode 100644 frontend/src/components/FeatureGuard/FeatureAttention.vue delete mode 100644 frontend/src/components/FileContentPreviewModal.vue delete mode 100644 frontend/src/components/Instance/InstanceSyncButton.vue delete mode 100644 frontend/src/components/Permission/NoPermissionPlaceholder.vue delete mode 100644 frontend/src/components/Permission/PermissionGuardWrapper.vue delete mode 100644 frontend/src/components/RequiredStar.vue delete mode 100644 frontend/src/components/RoleGrantPanel/MaxRowCountSelect.vue delete mode 100644 frontend/src/components/SQLReview/components/RuleConfigComponents/BooleanComponent.vue delete mode 100644 frontend/src/components/SQLReview/components/RuleConfigComponents/NumberComponent.vue delete mode 100644 frontend/src/components/SQLReview/components/RuleConfigComponents/StringArrayComponent.vue delete mode 100644 frontend/src/components/SQLReview/components/RuleConfigComponents/StringComponent.vue delete mode 100644 frontend/src/components/SQLReview/components/RuleConfigComponents/TemplateComponent.vue delete mode 100644 frontend/src/components/misc/MaskSpinner.vue delete mode 100644 frontend/src/components/misc/SQLUploadButton.vue delete mode 100644 frontend/src/components/v2/TabFilter/TabFilter.vue delete mode 100644 frontend/src/components/v2/TabFilter/index.ts delete mode 100644 frontend/src/components/v2/TabFilter/types.ts create mode 100644 frontend/src/react/no-react-to-vue-imports.test.ts diff --git a/docs/plans/2026-05-12-phase-a-legacy-primitives-design.md b/docs/plans/2026-05-12-phase-a-legacy-primitives-design.md new file mode 100644 index 00000000000000..d284986513deda --- /dev/null +++ b/docs/plans/2026-05-12-phase-a-legacy-primitives-design.md @@ -0,0 +1,111 @@ +# Phase A — Strip Legacy Primitives: Design + +**Date:** 2026-05-12 +**Parent doc:** [2026-05-12-react-migration-status-and-plan.md](./2026-05-12-react-migration-status-and-plan.md) +**Playbook:** [2026-04-08-react-migration-playbook.md](./2026-04-08-react-migration-playbook.md) + +--- + +## Goal + +**No `.tsx` file imports any `.vue` file.** + +After Phase A, every remaining `.vue` file in `frontend/src/components/` is imported only by other `.vue` files. The Vue and React layers are cleanly separated. Phase B (app shell, router, state) can then delete whole subtrees of Vue at a time without cross-framework concerns. + +## Strategy: defer-until-callers-die + +Each `.vue` file in `frontend/src/components/` is classified by who imports it: + +| Bucket | Vue callers | React callers | Phase A action | +|---|---|---|---| +| **DEAD** | 0 | 0 | Delete. No migration. | +| **REACT-ONLY** | 0 | ≥1 | Swap React callers to a React equivalent; delete the Vue file in the same change. | +| **MIXED** | ≥1 | ≥1 | Out of scope — handled opportunistically. The Vue file stays for its Vue callers. | +| **VUE-ONLY** | ≥1 | 0 | Out of scope — defer to Phase B (will retire with its Vue callers). | + +Phase A acts on DEAD and REACT-ONLY only. MIXED files become eligible the moment their React callers drop to zero — those are handled as one-off cleanup PRs as engineers touch the area, not as part of Phase A. + +## Scope: one PR + +A single PR — "Phase A: drop React→Vue cross-framework imports" — covering: + +### 1. Dead-code deletion (4 files) + +`git rm` only, no replacements: + +- `frontend/src/components/RequiredStar.vue` +- `frontend/src/components/EditEnvironmentDrawer.vue` +- `frontend/src/components/Permission/NoPermissionPlaceholder.vue` +- `frontend/src/components/misc/MaskSpinner.vue` + +### 2. REACT-ONLY swaps (~20 files) + +For each, update every React caller to import the existing React equivalent (or remove the obsolete re-export), then delete the Vue file. All replacements already exist in `frontend/src/react/components/` or `frontend/src/react/components/ui/`. + +| Vue file(s) to delete | React callers | Replacement | +|---|---|---| +| `EllipsisText.vue` | 13 | `react/components/ui/ellipsis-text.tsx` | +| `FeatureGuard/FeatureAttention.vue` | 20 | `react/components/FeatureAttention.tsx` | +| `AdvancedSearch/*` (5 files) | 21 (re-exports) | `react/components/AdvancedSearch.tsx` | +| `DatabaseInfo.vue` | 4 | React component / inline call sites | +| `Instance/InstanceSyncButton.vue` | 3 | `react/components/instance/InstanceSyncButton.tsx` | +| `DatabaseDetail/SyncDatabaseButton.vue` | 1 | React equivalent in `sql-editor/ResultView` | +| `RoleGrantPanel/MaxRowCountSelect.vue` | 2 | `react/components/MaxRowCountSelect.tsx` | +| `misc/SQLUploadButton.vue` | 2 | `react/components/sql-editor/StandardPanel/SQLUploadButton.tsx` | +| `v2/Container/*` (2 files) | 4 | Sheet / drawer primitives in `react/components/ui/` | +| `v2/TabFilter.vue` | 3 | React equivalent / re-export cleanup | +| `SQLReview/RuleConfigComponents/*` (5 files) | 2 (re-exports) | Re-export cleanup | + +**Total: ~24 Vue files deleted.** + +### 3. CI guard + +Extend `no-legacy-vue-deps.test.ts` (or add a sibling test) to fail on any `.tsx` file that imports a `.vue` file. This locks in the win and prevents regression while MIXED files are picked off opportunistically. + +The guard's allowlist starts empty — every MIXED file (`LearnMoreLink`, `FeatureBadge`, `FeatureModal`, `UserAvatar`, `MonacoEditor/*`, `Icon/*`, `v2/Button/*`, `v2/Form/*`, `v2/Select/*`, `v2/Model/*`, plus the long tail) needs an explicit entry until its React callers are migrated off. As each MIXED file becomes REACT-ONLY (or DEAD), its allowlist entry is removed and the file deleted. + +## Out of scope for Phase A + +These stay; each is its own future PR or waits for Phase B: + +**MIXED — opportunistic cleanup PRs** (one per primitive when an engineer touches the area): + +- `LearnMoreLink.vue` (17 React callers) +- `FeatureGuard/FeatureModal.vue` (12), `FeatureBadge.vue` (29) +- `UserAvatar.vue` (9) +- `FileContentPreviewModal.vue` (2), `HighlightCodeBlock.vue` (1) +- `MonacoEditor/*` (21 — many likely re-exports; verify when picked up) +- `Icon/*` (81 — likely many re-exports; verify when picked up) +- `v2/Button/*` (232 — count includes transitive matches; needs filtering) +- `v2/Form/*` (44), `v2/Select/*` (85), `v2/Model/*` (4) +- Long tail: `PermissionGuardWrapper`, `InputWithTemplate`, `SpannerQueryPlan/*`, `ErrorList`, `YouTag` + +**VUE-ONLY — defer to Phase B** (no React callers; retires when its Vue caller dies): + +- `ReleaseRemindModal.vue` (called from `BodyLayout.vue`) +- `misc/OverlayStackManager.vue` (called from `App.vue`, `BBModal.vue`) +- `misc/AccountTag.vue` +- `User/Settings/UserDataTableByGroup/cells/GroupNameCell.vue` +- `Member/MemberDataTable/cells/RoleCell.vue`, `UserRolesCell.vue` +- `InputWithTemplate/AutoWidthInput.vue` + +## Validation + +Per the playbook: + +1. `pnpm --dir frontend fix` +2. `pnpm --dir frontend check` +3. `pnpm --dir frontend type-check` +4. `pnpm --dir frontend test` — includes the new `.tsx`→`.vue` guard +5. Manual smoke: open every page whose React caller list was touched (settings pages, project pages, instance detail) and verify nothing renders broken. + +## Done when + +- `rg -l '\.vue["\x27]' frontend/src/**/*.tsx` returns only the MIXED files listed in the guard's allowlist. +- The four DEAD files are gone. +- The ~20 REACT-ONLY files are gone and their callers reference the React replacement. +- The CI guard exists and passes. + +## Rough cost + +One PR, touching ~24 `.vue` deletions + ~80 React caller updates (mostly mechanical import path swaps) + 1 test file. Estimated 1–2 days of focused work, including manual smoke verification. diff --git a/docs/plans/2026-05-12-phase-a-legacy-primitives-plan.md b/docs/plans/2026-05-12-phase-a-legacy-primitives-plan.md new file mode 100644 index 00000000000000..c7a6641541a072 --- /dev/null +++ b/docs/plans/2026-05-12-phase-a-legacy-primitives-plan.md @@ -0,0 +1,568 @@ +# Phase A — Strip Legacy Primitives: Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Delete 21 orphan `.vue` files in `frontend/src/components/` and add a CI guard preventing any future `.vue` import from React-side code under `frontend/src/react/`. + +**Architecture:** Single PR. Every targeted Vue file has zero remaining React-side callers (audit verified) and either zero callers anywhere or only sibling `index.ts` re-exports. The CI guard is a new vitest test that globs `frontend/src/react/**/*.{ts,tsx}` and asserts no source contains a `.vue` import, with a small explicit allowlist for mount-bridges (deferred to Phase B). The plan is "delete + verify + commit" repeated by group; no behavioral changes. + +**Tech Stack:** Vitest, TypeScript, ripgrep, pnpm. + +**Spec:** [`docs/plans/2026-05-12-phase-a-legacy-primitives-design.md`](./2026-05-12-phase-a-legacy-primitives-design.md) + +--- + +## Pre-work: Confirm baseline + +Before starting any task, confirm the working tree is clean and the baseline checks pass. + +- [ ] **Step 0.1:** `git status` — confirm clean tree (or only untracked plan docs). +- [ ] **Step 0.2:** `pnpm --dir frontend type-check` — confirm passes. +- [ ] **Step 0.3:** `pnpm --dir frontend test` — confirm passes (existing tests, including `no-legacy-vue-deps.test.ts`). + +If any baseline check fails, stop and report — do not start deletions. + +--- + +## Task 1: Add the React→Vue import guard + +**Goal:** New vitest test that fails if any file under `frontend/src/react/**/*.{ts,tsx}` contains a `.vue` import, except a small allowlist of mount-bridges deferred to Phase B. + +**Files:** +- Create: `frontend/src/react/no-react-to-vue-imports.test.ts` + +- [ ] **Step 1.1: Write the test** + +Create `frontend/src/react/no-react-to-vue-imports.test.ts`: + +```typescript +import { describe, expect, test } from "vitest"; + +// Every file under frontend/src/react/ that is *.ts or *.tsx (the React layer). +// .vue files are skipped: by definition .vue is Vue-side and is allowed to import +// other .vue files. +const sources = import.meta.glob("./**/*.{ts,tsx}", { + query: "?raw", + import: "default", + eager: true, +}) as Record; + +// Mount-bridge Vue files that React code is permitted to import until Phase B +// retires the Vue app shell. Adding new entries here requires explicit review. +const allowedVueImports = new Set([ + "@/components/SessionExpiredSurfaceMount.vue", + "@/components/AgentWindowMount.vue", +]); + +const vueImportPattern = /from\s+["']([^"']+\.vue)["']/g; + +describe("React layer must not import .vue files", () => { + test("no .tsx or .ts file under frontend/src/react/ imports a .vue file", () => { + const violations: string[] = []; + for (const [file, source] of Object.entries(sources)) { + // Don't scan this guard itself (it contains .vue strings as test data + // in the allowlist above). + if (file.endsWith("/no-react-to-vue-imports.test.ts")) continue; + // Don't scan the sibling guard (same reason — it has .vue strings as + // banned-import test data). + if (file.endsWith("/no-legacy-vue-deps.test.ts")) continue; + + let match: RegExpExecArray | null; + vueImportPattern.lastIndex = 0; + while ((match = vueImportPattern.exec(source)) !== null) { + const importPath = match[1]; + if (!allowedVueImports.has(importPath)) { + violations.push(`${file}: ${importPath}`); + } + } + } + expect(violations).toEqual([]); + }); +}); +``` + +- [ ] **Step 1.2: Run the test — verify it passes on the current tree** + +Run: `pnpm --dir frontend exec vitest run src/react/no-react-to-vue-imports.test.ts` + +Expected: 1 test passing. The only legitimate Vue import in the React layer is `SessionExpiredSurfaceMount.vue` from `SessionExpiredSurface.test.tsx`, which is allowlisted. + +**If the test fails** with violations: stop. The audit missed a real React→Vue import. Report which file and import; do not proceed to deletions. + +- [ ] **Step 1.3: Commit** + +```bash +git add frontend/src/react/no-react-to-vue-imports.test.ts +git commit -m "test(frontend): add CI guard blocking React-layer imports of .vue files + +Mount-bridges (SessionExpiredSurfaceMount, AgentWindowMount) are +allowlisted until Phase B retires the Vue app shell. +" +``` + +--- + +## Task 2: Delete files with zero callers anywhere + +**Goal:** Delete 9 Vue files that have zero importers (no Vue callers, no React callers, no `index.ts` re-exports). + +**Files to delete:** +- `frontend/src/components/RequiredStar.vue` +- `frontend/src/components/EditEnvironmentDrawer.vue` +- `frontend/src/components/Permission/NoPermissionPlaceholder.vue` +- `frontend/src/components/misc/MaskSpinner.vue` +- `frontend/src/components/DatabaseInfo.vue` +- `frontend/src/components/Instance/InstanceSyncButton.vue` +- `frontend/src/components/DatabaseDetail/SyncDatabaseButton.vue` +- `frontend/src/components/RoleGrantPanel/MaxRowCountSelect.vue` +- `frontend/src/components/misc/SQLUploadButton.vue` + +- [ ] **Step 2.1: Re-verify each file has zero callers** + +For each file in the list, run: + +```bash +for f in RequiredStar EditEnvironmentDrawer NoPermissionPlaceholder MaskSpinner DatabaseInfo InstanceSyncButton SyncDatabaseButton MaxRowCountSelect SQLUploadButton; do + echo "=== $f.vue ===" + rg -l --type-add 'tsx:*.tsx' --type-add 'vue:*.vue' -tts -tvue -ttsx "${f}\.vue" frontend/src/ 2>/dev/null \ + | grep -v "^frontend/src/components/.*${f}\.vue$" +done +``` + +Expected: each file shows zero callers (lines may appear only in `.tsx` files that contain the basename in a code comment like `// Inline replacement for SyncDatabaseButton.vue` — manually verify these are comments, not imports). + +The two known comment-only hits are: +- `frontend/src/react/components/sql-editor/ResultView/ResultView.tsx` references `SyncDatabaseButton.vue` in a comment +- `frontend/src/react/components/sql-editor/StandardPanel/SQLUploadButton.tsx` references `SQLUploadButton.vue` in a comment + +If any file shows a real `import ... from "....vue"` line, stop and report — that file belongs in a different bucket. + +- [ ] **Step 2.2: Delete the files** + +```bash +git rm \ + frontend/src/components/RequiredStar.vue \ + frontend/src/components/EditEnvironmentDrawer.vue \ + frontend/src/components/Permission/NoPermissionPlaceholder.vue \ + frontend/src/components/misc/MaskSpinner.vue \ + frontend/src/components/DatabaseInfo.vue \ + frontend/src/components/Instance/InstanceSyncButton.vue \ + frontend/src/components/DatabaseDetail/SyncDatabaseButton.vue \ + frontend/src/components/RoleGrantPanel/MaxRowCountSelect.vue \ + frontend/src/components/misc/SQLUploadButton.vue +``` + +- [ ] **Step 2.3: Check if any now-empty parent directories should be removed** + +After deletion, check directories that might be empty: + +```bash +for dir in frontend/src/components/Permission frontend/src/components/Instance frontend/src/components/DatabaseDetail frontend/src/components/RoleGrantPanel; do + if [ -d "$dir" ]; then + contents=$(ls "$dir" 2>/dev/null) + if [ -z "$contents" ]; then + echo "EMPTY: $dir — remove" + rmdir "$dir" + else + echo "$dir has: $contents" + fi + fi +done +``` + +For each remaining directory: if every file in it is also being deleted in this PR or has zero callers, plan to delete in a later task. Otherwise leave alone. + +Expected as of audit: +- `Permission/` — `PermissionGuardWrapper.vue` remains (has callers); leave dir. +- `Instance/` — should be empty after deletion; `rmdir`. +- `DatabaseDetail/` — should be empty; `rmdir`. +- `RoleGrantPanel/` — should be empty; `rmdir`. + +- [ ] **Step 2.4: Verify build & guard still pass** + +```bash +pnpm --dir frontend type-check +pnpm --dir frontend exec vitest run src/react/no-react-to-vue-imports.test.ts src/react/no-legacy-vue-deps.test.ts +``` + +Expected: both pass. + +- [ ] **Step 2.5: Commit** + +```bash +git add -A frontend/src/components/ +git commit -m "chore(frontend): drop orphan Vue files with zero callers + +Removes 9 .vue files whose call sites already migrated to React or +were never used. Empty parent directories cleaned up. +" +``` + +--- + +## Task 3: Delete the v2/TabFilter directory + +**Goal:** Remove `v2/TabFilter/TabFilter.vue` and its `index.ts` re-export plumbing. Update `v2/index.ts` to stop re-exporting from `./TabFilter`. + +**Files:** +- Delete: `frontend/src/components/v2/TabFilter/TabFilter.vue` +- Delete: `frontend/src/components/v2/TabFilter/index.ts` +- Delete: `frontend/src/components/v2/TabFilter/types.ts` (if no external callers — verify in step 3.1) +- Modify: `frontend/src/components/v2/index.ts` — remove `export * from "./TabFilter";` + +- [ ] **Step 3.1: Verify no external caller imports TabFilter or its types** + +```bash +echo "=== Imports of TabFilter (outside the dir itself) ===" +rg -n --type-add 'tsx:*.tsx' --type-add 'vue:*.vue' -tts -tvue -ttsx 'TabFilter' frontend/src/ 2>/dev/null \ + | grep -v '^frontend/src/components/v2/TabFilter/' + +echo "" +echo "=== Imports of v2/TabFilter/types ===" +rg -n --type-add 'tsx:*.tsx' --type-add 'vue:*.vue' -tts -tvue -ttsx 'v2/TabFilter/types' frontend/src/ 2>/dev/null +``` + +Expected: only `frontend/src/components/v2/index.ts:2:export * from "./TabFilter";` (which we will remove) and zero types imports. + +If any other file imports `TabFilter` (e.g., `from "@/components/v2"`), stop and report — TabFilter has hidden consumers and cannot be deleted in this PR. + +- [ ] **Step 3.2: Remove the export line from v2/index.ts** + +Edit `frontend/src/components/v2/index.ts`: + +```typescript +// Before +export * from "./Select"; +export * from "./TabFilter"; +export * from "./Model"; +export * from "./Form"; +export * from "./Button"; +export * from "./Container"; + +// After +export * from "./Select"; +export * from "./Model"; +export * from "./Form"; +export * from "./Button"; +export * from "./Container"; +``` + +- [ ] **Step 3.3: Delete the directory** + +```bash +git rm -r frontend/src/components/v2/TabFilter/ +``` + +- [ ] **Step 3.4: Verify build still passes** + +```bash +pnpm --dir frontend type-check +``` + +Expected: pass. + +- [ ] **Step 3.5: Commit** + +```bash +git add frontend/src/components/v2/index.ts +git commit -m "chore(frontend): drop unused v2/TabFilter + +TabFilter had no callers outside its own re-export plumbing in v2/index.ts. +" +``` + +--- + +## Task 4: Delete FeatureGuard/FeatureAttention.vue + +**Goal:** Delete the orphan `FeatureAttention.vue` (React callers already use `@/react/components/FeatureAttention`). Update `FeatureGuard/index.ts` to stop re-exporting it. `FeatureBadge.vue` and `FeatureModal.vue` stay — they have Vue callers (`RoleSelect.vue`, `DatabaseView.vue`). + +**Files:** +- Delete: `frontend/src/components/FeatureGuard/FeatureAttention.vue` +- Modify: `frontend/src/components/FeatureGuard/index.ts` + +- [ ] **Step 4.1: Verify FeatureAttention has no callers** + +```bash +rg -n --type-add 'tsx:*.tsx' --type-add 'vue:*.vue' -tts -tvue -ttsx 'FeatureAttention' frontend/src/ 2>/dev/null \ + | grep -v '^frontend/src/components/FeatureGuard/FeatureAttention\.vue:' \ + | grep -v '^frontend/src/react/components/FeatureAttention\.tsx:' +``` + +Expected: only `frontend/src/components/FeatureGuard/index.ts` matches (the re-export we will remove) and React-side files importing `@/react/components/FeatureAttention` (the React replacement, not the Vue file). + +If a Vue file (`.vue`) other than `index.ts` matches, stop and report. + +- [ ] **Step 4.2: Update FeatureGuard/index.ts** + +Edit `frontend/src/components/FeatureGuard/index.ts`: + +```typescript +// Before +import FeatureAttention from "./FeatureAttention.vue"; +import FeatureBadge from "./FeatureBadge.vue"; +import FeatureModal from "./FeatureModal.vue"; + +export { FeatureAttention, FeatureBadge, FeatureModal }; + +// After +import FeatureBadge from "./FeatureBadge.vue"; +import FeatureModal from "./FeatureModal.vue"; + +export { FeatureBadge, FeatureModal }; +``` + +- [ ] **Step 4.3: Delete the Vue file** + +```bash +git rm frontend/src/components/FeatureGuard/FeatureAttention.vue +``` + +- [ ] **Step 4.4: Verify build still passes** + +```bash +pnpm --dir frontend type-check +``` + +Expected: pass. + +- [ ] **Step 4.5: Commit** + +```bash +git add frontend/src/components/FeatureGuard/index.ts +git commit -m "chore(frontend): drop orphan FeatureAttention.vue + +React callers already use @/react/components/FeatureAttention. +FeatureBadge/FeatureModal stay — Vue callers remain. +" +``` + +--- + +## Task 5: Delete SQLReview RuleConfigComponents + +**Goal:** The five `*Component.vue` files plus the `index.ts` that re-exports them have zero callers. Delete the whole set. `types.ts` and `utils.ts` in the same dir may also be orphaned — verify and delete if so. + +**Files:** +- Delete: `frontend/src/components/SQLReview/components/RuleConfigComponents/BooleanComponent.vue` +- Delete: `frontend/src/components/SQLReview/components/RuleConfigComponents/NumberComponent.vue` +- Delete: `frontend/src/components/SQLReview/components/RuleConfigComponents/StringArrayComponent.vue` +- Delete: `frontend/src/components/SQLReview/components/RuleConfigComponents/StringComponent.vue` +- Delete: `frontend/src/components/SQLReview/components/RuleConfigComponents/TemplateComponent.vue` +- Delete: `frontend/src/components/SQLReview/components/RuleConfigComponents/index.ts` +- Conditionally delete: `types.ts` and `utils.ts` in same dir (verify in step 5.1) + +- [ ] **Step 5.1: Verify the whole directory is orphaned** + +```bash +echo "=== Any imports of RuleConfigComponents (subpath) ===" +rg -n --type-add 'tsx:*.tsx' --type-add 'vue:*.vue' -tts -tvue -ttsx 'RuleConfigComponents' frontend/src/ 2>/dev/null \ + | grep -v '^frontend/src/components/SQLReview/components/RuleConfigComponents/' + +echo "" +echo "=== Imports of the dir's types.ts or utils.ts (by path) ===" +rg -n --type-add 'tsx:*.tsx' --type-add 'vue:*.vue' -tts -tvue -ttsx '(RuleConfigComponents/types|RuleConfigComponents/utils)' frontend/src/ 2>/dev/null +``` + +Expected: zero results for both. If anything matches outside the dir, only delete the `.vue` files + `index.ts` and leave `types.ts`/`utils.ts`. + +- [ ] **Step 5.2: Delete the directory if fully orphaned, else partial delete** + +If step 5.1 returned **zero external imports**: + +```bash +git rm -r frontend/src/components/SQLReview/components/RuleConfigComponents/ +``` + +Otherwise, delete only the Vue files + index.ts: + +```bash +git rm \ + frontend/src/components/SQLReview/components/RuleConfigComponents/BooleanComponent.vue \ + frontend/src/components/SQLReview/components/RuleConfigComponents/NumberComponent.vue \ + frontend/src/components/SQLReview/components/RuleConfigComponents/StringArrayComponent.vue \ + frontend/src/components/SQLReview/components/RuleConfigComponents/StringComponent.vue \ + frontend/src/components/SQLReview/components/RuleConfigComponents/TemplateComponent.vue \ + frontend/src/components/SQLReview/components/RuleConfigComponents/index.ts +``` + +- [ ] **Step 5.3: Verify build still passes** + +```bash +pnpm --dir frontend type-check +``` + +Expected: pass. + +- [ ] **Step 5.4: Commit** + +```bash +git add -A frontend/src/components/SQLReview/ +git commit -m "chore(frontend): drop unused SQLReview RuleConfigComponents + +Whole subtree had no callers. SQL review UI is fully React-side." +``` + +--- + +## Task 6: Delete AdvancedSearch Vue files + +**Goal:** Delete the five `.vue` files and the `index.ts` that re-exports them. Keep `types.ts` and `useCommonSearchScopeOptions.ts` — `frontend/src/utils/accessGrant.ts` imports from `@/components/AdvancedSearch/types`. + +**Files:** +- Delete: `frontend/src/components/AdvancedSearch/AdvancedSearch.vue` +- Delete: `frontend/src/components/AdvancedSearch/ScopeMenu.vue` +- Delete: `frontend/src/components/AdvancedSearch/ScopeTags.vue` +- Delete: `frontend/src/components/AdvancedSearch/TimeRange.vue` +- Delete: `frontend/src/components/AdvancedSearch/ValueMenu.vue` +- Delete: `frontend/src/components/AdvancedSearch/index.ts` +- Keep: `types.ts`, `useCommonSearchScopeOptions.ts` + +- [ ] **Step 6.1: Verify the Vue files have no callers outside the directory itself** + +```bash +echo "=== Imports of @/components/AdvancedSearch (any subpath) ===" +rg -n --type-add 'tsx:*.tsx' --type-add 'vue:*.vue' -tts -tvue -ttsx '@/components/AdvancedSearch' frontend/src/ 2>/dev/null +``` + +Expected output (this is the only allowed external import): +``` +frontend/src/utils/accessGrant.ts: ... } from "@/components/AdvancedSearch/types"; +``` + +If anything else matches (e.g., a default import of `@/components/AdvancedSearch`), stop and report. + +- [ ] **Step 6.2: Delete the Vue files and index.ts** + +```bash +git rm \ + frontend/src/components/AdvancedSearch/AdvancedSearch.vue \ + frontend/src/components/AdvancedSearch/ScopeMenu.vue \ + frontend/src/components/AdvancedSearch/ScopeTags.vue \ + frontend/src/components/AdvancedSearch/TimeRange.vue \ + frontend/src/components/AdvancedSearch/ValueMenu.vue \ + frontend/src/components/AdvancedSearch/index.ts +``` + +- [ ] **Step 6.3: Verify `types.ts` is still importable** + +```bash +rg -n 'AdvancedSearch/types' frontend/src/utils/accessGrant.ts +pnpm --dir frontend type-check +``` + +Expected: the import line shows; type-check passes. + +- [ ] **Step 6.4: Verify `useCommonSearchScopeOptions.ts` is still used** + +```bash +rg -n 'useCommonSearchScopeOptions' frontend/src/ 2>/dev/null \ + | grep -v '^frontend/src/components/AdvancedSearch/useCommonSearchScopeOptions\.ts:' +``` + +If zero results: the file is now orphaned — flag for the next opportunistic cleanup PR but leave it in this PR (deleting Vue-helper TS files that *might* still be referenced via dynamic patterns is risky for a delete-driven PR). + +- [ ] **Step 6.5: Commit** + +```bash +git add -A frontend/src/components/AdvancedSearch/ +git commit -m "chore(frontend): drop Vue AdvancedSearch components + +All consumers now use @/react/components/AdvancedSearch. +types.ts kept (still imported by utils/accessGrant.ts). +" +``` + +--- + +## Task 7: Final validation + +**Goal:** Confirm the full PR is clean: builds, lints, type-checks, all tests including the new guard, no regressions. + +- [ ] **Step 7.1: Run the full frontend check chain** + +```bash +pnpm --dir frontend fix +pnpm --dir frontend check +pnpm --dir frontend type-check +pnpm --dir frontend test +``` + +Expected: every command exits 0. + +If `fix` modifies any files (e.g., auto-formats remaining files after the deletions changed imports), stage and amend those into the most recent relevant commit — or land a small follow-up `style:` commit. Do not skip this step. + +- [ ] **Step 7.2: Build sanity check** + +```bash +pnpm --dir frontend build +``` + +Expected: build succeeds. (Heavy step — only required if any of the earlier steps modified `.ts`/`.tsx` files in `frontend/src/components/v2/index.ts` or `FeatureGuard/index.ts`. If only `.vue` files were deleted, type-check is sufficient.) + +- [ ] **Step 7.3: Verify file count delta** + +```bash +echo "Vue files remaining outside frontend/src/react/:" +fd -e vue . frontend/src 2>/dev/null | grep -v '/react/' | wc -l +``` + +Expected: **133** (was 154, minus 21 deleted in this PR). + +- [ ] **Step 7.4: Spot manual smoke** + +The deleted files have no live callers, but as a final safety check, start the dev server and click through 3–4 high-traffic React pages that previously referenced near-neighbors of these files: + +```bash +PG_URL=postgresql://bbdev@localhost/bbdev pnpm --dir frontend dev +``` + +Visit and verify no broken UI/console errors on: +- A project issue list (uses `AdvancedSearch.tsx` — confirm search filters still work) +- A workspace settings page with subscription gating (uses `FeatureAttention.tsx` — confirm gating renders) +- Instance detail page (used `InstanceSyncButton` — confirm sync button renders) +- SQL Editor home (uses `MaxRowCountSelect`, `SQLUploadButton` — confirm both controls work) + +Report any console errors before proceeding to PR. + +--- + +## Task 8: PR preparation + +- [ ] **Step 8.1: Review commit log** + +```bash +git log --oneline main..HEAD +``` + +Expected: 6 commits (one per task 1–6). + +- [ ] **Step 8.2: Push & open PR** + +```bash +git push -u origin +gh pr create --title "chore(frontend): drop 21 orphan Vue primitives + add React→Vue import guard" --body "$(cat <<'EOF' +## Summary +Phase A of the Vue→React migration ([status doc](docs/plans/2026-05-12-react-migration-status-and-plan.md), [design](docs/plans/2026-05-12-phase-a-legacy-primitives-design.md)). + +- Deletes 21 `.vue` files in `frontend/src/components/` whose call sites already migrated to React or were never used. +- Adds a new vitest guard (`no-react-to-vue-imports.test.ts`) that fails CI if any `frontend/src/react/**/*.{ts,tsx}` file imports a `.vue` file, with a small explicit allowlist for mount-bridges (`SessionExpiredSurfaceMount`, `AgentWindowMount`) deferred to Phase B. + +## Test plan +- [ ] `pnpm --dir frontend type-check` passes +- [ ] `pnpm --dir frontend test` passes (includes new guard) +- [ ] `pnpm --dir frontend check` passes +- [ ] `pnpm --dir frontend build` succeeds +- [ ] Manual smoke: AdvancedSearch, FeatureAttention, InstanceSyncButton, MaxRowCountSelect, SQLUploadButton call sites render without errors +EOF +)" +``` + +--- + +## Self-review notes + +- **Spec coverage:** Task 1 implements the CI guard requirement. Tasks 2–6 cover all 21 files listed in the spec's "Easy-delete sweep" section. Task 7 covers the spec's "Validation" subsection. The spec's "Out of scope" list (MIXED files and VUE-ONLY files) is honored — no plan task touches `LearnMoreLink`, `FeatureBadge`, `FeatureModal`, `UserAvatar`, `MonacoEditor/*`, `Icon/*`, `v2/Button|Form|Select|Model`, `ReleaseRemindModal`, `OverlayStackManager`, etc. +- **EllipsisText.vue deviation:** The spec listed `EllipsisText.vue` in the easy-delete sweep (13 React callers). Re-verification during planning revealed those 13 callers already import the React `ellipsis-text.tsx`; the only remaining caller is `frontend/src/components/v2/Select/RemoteResourceSelector/utils.tsx`, which is a Vue-JSX file (not React — Vue 3 supports `.tsx` via `@vitejs/plugin-vue-jsx`). EllipsisText.vue is therefore VUE-ONLY and deferred to Phase B. The CI guard's scope to `frontend/src/react/**` correctly excludes the Vue-JSX caller. +- **v2/Container/Drawer** deviation: The spec listed `v2/Container/*` in the easy-delete sweep. The audit found `frontend/src/plugins/ai/components/HistoryPanel/HistoryPanel.vue` still imports it. Deferred to Phase B. Not included in this plan. +- **Final delete count:** 21 `.vue` files + 4 `index.ts` edits (delete or modify) + 1 new test file. Matches the spec's "~24 Vue files" target. diff --git a/docs/plans/2026-05-12-react-migration-status-and-plan.md b/docs/plans/2026-05-12-react-migration-status-and-plan.md new file mode 100644 index 00000000000000..73de143afb6391 --- /dev/null +++ b/docs/plans/2026-05-12-react-migration-status-and-plan.md @@ -0,0 +1,152 @@ +# Vue → React Migration: Status & Remaining Plan + +**Date:** 2026-05-12 +**Goal:** Full Vue removal — every `.vue` file deleted, `pnpm remove vue vue-router pinia` ships, single-framework codebase. +**Companion doc:** [2026-04-08-react-migration-playbook.md](./2026-04-08-react-migration-playbook.md) (process rules, deletion safety, state preferences). + +--- + +## Part 1 — Current State + +### Counts + +- **`.tsx` files:** 681 +- **`.vue` files:** 154 (outside `frontend/src/react/`) +- **React pages:** 167 (auth 11, project 100, settings 53, workspace 3) +- **Shared React UI primitives:** 46 in `frontend/src/react/components/ui/` +- **React SQL Editor surface:** 223 `.tsx` files under `frontend/src/react/components/sql-editor/` + +### Routing — fully bridged + +Every route file under `frontend/src/router/` already delegates to React via `ReactPageMount.vue` or `ReactRouteShellBridge.vue`: + +- `auth.ts` — all auth/consent routes +- `setup.ts` — first-run setup +- `dashboard/index.ts`, `dashboard/workspace.ts`, `dashboard/instance.ts`, `dashboard/workspaceSetting.ts`, `dashboard/projectV1.ts` — every leaf +- `sqlEditor.ts` — parent is a one-line Vue render-only shim that mounts ``; children are `NoopRouteComponent` (the React layout reads `useCurrentRoute()` and decides what to show). All panel content is React. + +### Cross-layer bridges + +- `frontend/src/react/hooks/useVueState.ts` — React subscribes to Vue reactive state (Pinia stores, refs, computed) via `useSyncExternalStore`. +- `frontend/src/react/shell-bridge.ts` — custom events for `bb.react-locale-change`, `bb.react-notification`, `bb.react-quickstart-reset`. +- `frontend/src/react/router/` — `useCurrentRoute()` / `useNavigate()` wrap vue-router for React consumers. + +### What's migrated + +| Area | Status | +|---|---| +| Auth (signin, signup, OAuth/OIDC, password reset, 2FA, profile setup, consent) | ✅ React | +| Workspace dashboard (Projects, Instances, Databases, Environments, MyIssues, 403/404) | ✅ React | +| Workspace settings (53 pages: members, roles, users, instances, environments, groups, IDPs, approvals, SQL review, semantic types, classifications, masking, risk, audit logs, subscription, general, profile, service accounts, workload identities, MCP) | ✅ React | +| Project pages (100 pages: issue detail, plan detail, release detail, database detail, changelog, revisions, data export, webhooks, audit logs, database groups, GitOps, masking exemptions, access grants) | ✅ React | +| **SQL Editor (all panels: editor, results, schema, connection, tabs, worksheets, history, diagram, terminal, access, masking, request drawers, save/upload, compact editor)** | **✅ React** | +| Shared UI primitives (46 Base UI wrappers: input, button, dialog, sheet, popover, dropdown, table, tabs, tree, select, combobox, switch, tooltip, …) | ✅ React | + +### What's left + +**1. `frontend/src/components/` — 154 `.vue` files:** + +| Subdirectory | Files | Notes | +|---|---|---| +| `v2/` | 52 | Button, Container, Form, Model, Select, TabFilter — foundational primitives; React equivalents already exist in `react/components/ui/` | +| `Icon/` | 18 | Thin wrappers over icon libs | +| `misc/` | 6 | | +| `MonacoEditor/` | 5 | | +| `SQLReview/` | 5 | | +| `AdvancedSearch/` | 5 | | +| `FeatureGuard/` | 3 | | +| `User/`, `Member/`, `Permission/`, `InputWithTemplate/`, `SpannerQueryPlan/` | 2 each (10 total) | | +| `DatabaseDetail/`, `Instance/`, `RoleGrantPanel/` | 1 each | | +| Top-level singletons (`DatabaseInfo`, `SessionExpiredSurfaceMount`, `LearnMoreLink`, `ReleaseRemindModal`, `RequiredStar`, `HighlightCodeBlock`, `FileContentPreviewModal`, `EditEnvironmentDrawer`, `EllipsisText`, `AgentWindowMount`) | 10 | | + +**2. App shell & framework:** + +- `frontend/src/App.vue`, `AuthContext.vue`, `NotificationContext.vue` +- `frontend/src/layouts/BodyLayout.vue`, `DashboardLayout.vue` +- `frontend/src/mountSidebar.ts`, `mountProjectSidebar.ts` +- `frontend/src/router/` — vue-router driving the URL +- `frontend/src/store/` — Pinia stores (still the source of truth for some domains; read from React via `useVueState`) +- `frontend/src/main.ts`, `init.ts` — Vue bootstrap +- The one-line `SQLEditorLayoutComponent` Vue shim in `router/sqlEditor.ts` (retires when Vue Router is replaced) + +**3. React-native stores already in place:** `frontend/src/react/stores/app/` contains 10 stores (auth, workspace, project, preferences, notification, iam, …). Future store migrations land here. + +### Rough completion read + +- **Routed page surface:** 100% React (only the SQL Editor parent route remains a thin Vue shim that mounts React) +- **Feature components:** ~78% React (~681 `.tsx` vs 154 `.vue`) +- **Foundation (shell + router + state):** 0% — still entirely Vue + +--- + +## Part 2 — Migration Plan + +Two phases, sequenced **user-value first**: visible improvements first, foundation last. (Previous "Phase A — finish SQL Editor" is complete and folded into Phase B's layout retirement.) + +### Phase A — Strip legacy primitives & one-offs + +**Strategy:** delete-driven. Each PR migrates a primitive *and* updates all its callers in the same change. No long-lived dual implementations. Most React replacements already exist in `react/components/ui/` (46 components) — this is largely find-and-replace. + +**Order (cheap → load-bearing):** + +1. **Top-level singletons (1–2 PRs)** — `RequiredStar`, `LearnMoreLink`, `EllipsisText`, `HighlightCodeBlock`, `FileContentPreviewModal`, `ReleaseRemindModal`, `DatabaseInfo`, `EditEnvironmentDrawer`. Defer `SessionExpiredSurfaceMount` and `AgentWindowMount` to Phase B (they are mount-bridges that disappear with the shell). +2. **`Icon/` (1 PR)** — 18 wrappers; bulk replace with React icon equivalents. +3. **Small feature dirs (5 PRs)** — `MonacoEditor/`, `SQLReview/`, `AdvancedSearch/`, `FeatureGuard/`, `misc/`. One PR per dir. +4. **`components/v2/` (6–7 PRs)** — Button, Container, Form, Model, Select, TabFilter. One PR per subdir; final cleanup PR deletes the `v2/` tree. +5. **Other small dirs (1–2 PRs)** — User, Permission, Member, Instance, InputWithTemplate, RoleGrantPanel, DatabaseDetail, SpannerQueryPlan. + +**Done when:** `frontend/src/components/` contains only files that will be deleted alongside their layouts in Phase B. + +**Estimated:** 10–15 PRs. + +### Phase B — App shell, router, Vue extraction + +The longest and riskiest phase. Each step forces a decision that the bridge has so far deferred. + +#### B1. Router migration (vue-router → React Router DOM) + +The hardest single piece. Introduce React Router DOM at the *root* and have it own the URL. Port routes from `frontend/src/router/{auth,setup,sqlEditor,dashboard/*}.ts` into React route trees. + +The `useCurrentRoute()` / `useNavigate()` hooks already abstract route access — swap their *implementation* (vue-router refs → RR DOM hooks) without touching consumers. + +**Risk:** param/query/hash semantics differ between vue-router and RR DOM (trailing slashes, optional params, `RouteLocationNormalized` shape). Plan for a careful dev-verification window before merge. Hard cutover; no feature flag (router cannot meaningfully dual-stack at runtime). + +#### B2. App shell + layouts + +Convert `App.vue`, `AuthContext.vue`, `NotificationContext.vue`, `layouts/BodyLayout.vue`, `layouts/DashboardLayout.vue`, `mountSidebar.ts`, `mountProjectSidebar.ts`, and `shell-bridge.ts` event consumers. + +The shell currently *hosts* React pages; after this step React hosts everything. `useVueState`, `shell-bridge.ts`, and `ReactPageMount.vue` retire — once no reactive state is Vue-owned, the bridges have no consumers. `SessionExpiredSurfaceMount.vue` and `AgentWindowMount.vue` disappear here. The `SQLEditorLayoutComponent` Vue shim in `router/sqlEditor.ts` also retires. + +#### B3. State migration (Pinia → React state) + +Per the playbook, deferred until concrete problem. After B2, Pinia stores have no Vue component consumers — only React via `useVueState`. Options: + +- **Keep Pinia** as a pure data layer. `createPinia()` works without Vue components. +- **Port store-by-store** to the existing pattern in `react/stores/app/` (already 10 stores in place). + +Recommendation: port. Pinia stays a transitive dep of `vue` and blocks B4 otherwise. Use the same delete-driven pattern as Phase A — one store per PR, update all `useVueState` callers in the same change. + +#### B4. Final extraction + +- `pnpm remove vue vue-router pinia @vue/* vue-i18n vue-tsc @vitejs/plugin-vue` +- Delete `frontend/src/{App.vue,AuthContext.vue,NotificationContext.vue,init.ts,mount.ts,layouts,store,router}` and Vue shims (`shims-vue-*.d.ts`) +- Move `frontend/src/react/*` up one level (drop the `react/` namespace prefix) +- Switch Vite plugins: drop `@vitejs/plugin-vue`; promote `react-tsx-transform` to the standard React plugin +- Collapse `tsconfig.json` + `tsconfig.react.json` into one +- Retire `no-legacy-vue-deps.test.ts` enforcement +- Merge `frontend/src/react/locales/` into a single locales tree; drop `vue-i18n` callers +- Move framework-neutral `frontend/src/views/sql-editor/` utilities (events, hooks, types — no `.vue` files remain) to wherever they best fit in the unified layout + +**Estimated Phase B:** 8–12 PRs, sequenced B1 → B2 → B3 → B4. B1 is the riskiest single PR in the entire migration. + +--- + +## Part 3 — Cross-Cutting Rules + +- **One PR, one replacement.** Every migration PR deletes the Vue file(s) it replaces. No dual-stack components. +- **Locales.** New strings land in `frontend/src/react/locales/`. Each phase opportunistically removes unused keys from `frontend/src/locales/`. Final merge happens in B4. +- **i18n compatibility.** `vue-i18n` and `react-i18next` stay parallel until B4. +- **State.** Stick with Pinia + `useVueState` until B3. Don't introduce new Zustand stores during Phase A unless a concrete problem demands it (playbook rule). +- **Shared UI.** Always check `frontend/src/react/components/ui/` before hand-rolling a control (AGENTS.md rule). +- **Composite-PK tests.** Backend tests are out of scope for this migration; no expected impact. +- **Testing.** Existing unit tests + manual QA per surface. No new E2E gate is proposed unless coverage gaps surface during Phase B1. diff --git a/frontend/src/components/AdvancedSearch/AdvancedSearch.vue b/frontend/src/components/AdvancedSearch/AdvancedSearch.vue deleted file mode 100644 index d14d54ebb44c4d..00000000000000 --- a/frontend/src/components/AdvancedSearch/AdvancedSearch.vue +++ /dev/null @@ -1,734 +0,0 @@ - - - diff --git a/frontend/src/components/AdvancedSearch/ScopeMenu.vue b/frontend/src/components/AdvancedSearch/ScopeMenu.vue deleted file mode 100644 index 2380a9648846d8..00000000000000 --- a/frontend/src/components/AdvancedSearch/ScopeMenu.vue +++ /dev/null @@ -1,97 +0,0 @@ - - - diff --git a/frontend/src/components/AdvancedSearch/ScopeTags.vue b/frontend/src/components/AdvancedSearch/ScopeTags.vue deleted file mode 100644 index 4bc8dd08f9a579..00000000000000 --- a/frontend/src/components/AdvancedSearch/ScopeTags.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - diff --git a/frontend/src/components/AdvancedSearch/TimeRange.vue b/frontend/src/components/AdvancedSearch/TimeRange.vue deleted file mode 100644 index f9d293d043b949..00000000000000 --- a/frontend/src/components/AdvancedSearch/TimeRange.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - - - diff --git a/frontend/src/components/AdvancedSearch/ValueMenu.vue b/frontend/src/components/AdvancedSearch/ValueMenu.vue deleted file mode 100644 index 4c132854222259..00000000000000 --- a/frontend/src/components/AdvancedSearch/ValueMenu.vue +++ /dev/null @@ -1,159 +0,0 @@ - - - diff --git a/frontend/src/components/AdvancedSearch/index.ts b/frontend/src/components/AdvancedSearch/index.ts deleted file mode 100644 index dd74943a1ed76d..00000000000000 --- a/frontend/src/components/AdvancedSearch/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import AdvancedSearch from "./AdvancedSearch.vue"; -import TimeRange from "./TimeRange.vue"; -import { useCommonSearchScopeOptions } from "./useCommonSearchScopeOptions"; - -export default AdvancedSearch; - -export { TimeRange, useCommonSearchScopeOptions }; diff --git a/frontend/src/components/AdvancedSearch/useCommonSearchScopeOptions.ts b/frontend/src/components/AdvancedSearch/useCommonSearchScopeOptions.ts deleted file mode 100644 index 2916a0eb4f7a6c..00000000000000 --- a/frontend/src/components/AdvancedSearch/useCommonSearchScopeOptions.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { VNode } from "vue"; -import { computed, h, unref } from "vue"; -import { - EnvironmentV1Name, - InstanceV1Name, - ProjectV1Name, - RichEngineName, -} from "@/components/v2"; -import { t } from "@/plugins/i18n"; -import { - environmentNamePrefix, - useEnvironmentV1Store, - useInstanceV1Store, - useProjectV1Store, -} from "@/store"; -import type { MaybeRef } from "@/types"; -import { UNKNOWN_ENVIRONMENT_NAME, unknownEnvironment } from "@/types"; -import { Engine } from "@/types/proto-es/v1/common_pb"; -import type { SearchScopeId } from "@/utils"; -import { - extractEnvironmentResourceName, - extractInstanceResourceName, - extractProjectResourceName, - getDefaultPagination, - supportedEngineV1List, -} from "@/utils"; -import type { ScopeOption, ValueOption } from "./types"; - -export const useCommonSearchScopeOptions = ( - supportOptionIdList: MaybeRef -) => { - const projectStore = useProjectV1Store(); - const instanceStore = useInstanceV1Store(); - const environmentStore = useEnvironmentV1Store(); - - // fullScopeOptions provides full search scopes and options. - // we need this as the source of truth. - const fullScopeOptions = computed((): ScopeOption[] => { - const scopeCreators = { - project: () => ({ - id: "project", - title: t("issue.advanced-search.scope.project.title"), - description: t("issue.advanced-search.scope.project.description"), - search: ({ - keyword, - nextPageToken, - }: { - keyword: string; - nextPageToken?: string; - }) => { - return projectStore - .fetchProjectList({ - pageToken: nextPageToken, - pageSize: getDefaultPagination(), - filter: { - query: keyword, - }, - }) - .then((resp) => ({ - nextPageToken: resp.nextPageToken, - options: resp.projects.map((project) => { - const name = extractProjectResourceName(project.name); - return { - value: name, - keywords: [ - name, - project.title, - extractProjectResourceName(project.name), - ], - render: () => { - const children: VNode[] = [ - h(ProjectV1Name, { project: project, link: false }), - ]; - return h( - "div", - { class: "flex items-center gap-x-2" }, - children - ); - }, - }; - }), - })); - }, - }), - instance: () => ({ - id: "instance", - title: t("issue.advanced-search.scope.instance.title"), - description: t("issue.advanced-search.scope.instance.description"), - search: ({ - keyword, - nextPageToken, - }: { - keyword: string; - nextPageToken?: string; - }) => { - return instanceStore - .fetchInstanceList({ - pageToken: nextPageToken, - pageSize: getDefaultPagination(), - filter: { - query: keyword, - }, - silent: true, - }) - .then((resp) => ({ - nextPageToken: resp.nextPageToken, - options: resp.instances.map((ins) => { - const name = extractInstanceResourceName(ins.name); - return { - value: name, - keywords: [ - name, - ins.title, - String(ins.engine), - extractEnvironmentResourceName(ins.environment ?? ""), - ], - render: () => { - return h("div", { class: "flex items-center gap-x-1" }, [ - h(InstanceV1Name, { - instance: ins, - link: false, - tooltip: false, - }), - h(EnvironmentV1Name, { - environment: environmentStore.getEnvironmentByName( - ins.environment ?? "" - ), - link: false, - }), - ]); - }, - }; - }), - })); - }, - }), - environment: () => ({ - id: "environment", - title: t("issue.advanced-search.scope.environment.title"), - description: t("issue.advanced-search.scope.environment.description"), - options: [ - unknownEnvironment(), - ...environmentStore.environmentList, - ].map((env) => { - return { - value: env.id, - keywords: [`${environmentNamePrefix}${env.id}`, env.title], - custom: env.name === UNKNOWN_ENVIRONMENT_NAME, - render: () => - h(EnvironmentV1Name, { - environment: env, - link: false, - }), - }; - }), - }), - label: () => ({ - id: "label", - title: t("common.labels"), - description: t("issue.advanced-search.scope.label.description"), - allowMultiple: true, - }), - table: () => ({ - id: "table", - title: t("issue.advanced-search.scope.table.title"), - description: t("issue.advanced-search.scope.table.description"), - allowMultiple: false, - }), - engine: () => ({ - id: "engine", - title: t("issue.advanced-search.scope.engine.title"), - description: t("issue.advanced-search.scope.engine.description"), - options: supportedEngineV1List().map((engine) => { - return { - value: Engine[engine], - keywords: [Engine[engine].toLowerCase()], - render: () => h(RichEngineName, { engine, tag: "p" }), - }; - }), - allowMultiple: true, - }), - state: () => ({ - id: "state", - title: t("common.state"), - description: t("issue.advanced-search.scope.state.description"), - options: [ - { - value: "ACTIVE", - keywords: ["active", "ACTIVE"], - render: () => t("common.active"), - }, - { - value: "DELETED", - keywords: ["archived", "ARCHIVED", "deleted", "DELETED"], - render: () => t("common.archived"), - }, - { - value: "ALL", - keywords: ["all", "ALL"], - render: () => t("common.all"), - }, - ], - allowMultiple: false, - }), - } as Partial ScopeOption>>; - - const scopes: ScopeOption[] = []; - for (const id of unref(supportOptionIdList)) { - const create = scopeCreators[id]; - if (create) { - scopes.push(create()); - } - } - - return scopes; - }); - - return fullScopeOptions; -}; diff --git a/frontend/src/components/DatabaseDetail/SyncDatabaseButton.vue b/frontend/src/components/DatabaseDetail/SyncDatabaseButton.vue deleted file mode 100644 index 09899c8bb35b74..00000000000000 --- a/frontend/src/components/DatabaseDetail/SyncDatabaseButton.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - diff --git a/frontend/src/components/DatabaseInfo.vue b/frontend/src/components/DatabaseInfo.vue deleted file mode 100644 index 788a5c78ecc5c6..00000000000000 --- a/frontend/src/components/DatabaseInfo.vue +++ /dev/null @@ -1,83 +0,0 @@ - - - diff --git a/frontend/src/components/EditEnvironmentDrawer.vue b/frontend/src/components/EditEnvironmentDrawer.vue deleted file mode 100644 index 1746dd5f681eff..00000000000000 --- a/frontend/src/components/EditEnvironmentDrawer.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/frontend/src/components/FeatureGuard/FeatureAttention.vue b/frontend/src/components/FeatureGuard/FeatureAttention.vue deleted file mode 100644 index 89ae475983afdb..00000000000000 --- a/frontend/src/components/FeatureGuard/FeatureAttention.vue +++ /dev/null @@ -1,167 +0,0 @@ - - - diff --git a/frontend/src/components/FeatureGuard/index.ts b/frontend/src/components/FeatureGuard/index.ts index aacc1175bc8ed2..180817061dc4aa 100644 --- a/frontend/src/components/FeatureGuard/index.ts +++ b/frontend/src/components/FeatureGuard/index.ts @@ -1,5 +1,4 @@ -import FeatureAttention from "./FeatureAttention.vue"; import FeatureBadge from "./FeatureBadge.vue"; import FeatureModal from "./FeatureModal.vue"; -export { FeatureAttention, FeatureBadge, FeatureModal }; +export { FeatureBadge, FeatureModal }; diff --git a/frontend/src/components/FileContentPreviewModal.vue b/frontend/src/components/FileContentPreviewModal.vue deleted file mode 100644 index dc41232bcfa932..00000000000000 --- a/frontend/src/components/FileContentPreviewModal.vue +++ /dev/null @@ -1,100 +0,0 @@ - - - diff --git a/frontend/src/components/Instance/InstanceSyncButton.vue b/frontend/src/components/Instance/InstanceSyncButton.vue deleted file mode 100644 index 7dcf89cfb0e804..00000000000000 --- a/frontend/src/components/Instance/InstanceSyncButton.vue +++ /dev/null @@ -1,204 +0,0 @@ - - - diff --git a/frontend/src/components/Permission/NoPermissionPlaceholder.vue b/frontend/src/components/Permission/NoPermissionPlaceholder.vue deleted file mode 100644 index b92888acb4f758..00000000000000 --- a/frontend/src/components/Permission/NoPermissionPlaceholder.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/frontend/src/components/Permission/PermissionGuardWrapper.vue b/frontend/src/components/Permission/PermissionGuardWrapper.vue deleted file mode 100644 index 9ac218b1c0d3b4..00000000000000 --- a/frontend/src/components/Permission/PermissionGuardWrapper.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/components/RequiredStar.vue b/frontend/src/components/RequiredStar.vue deleted file mode 100644 index 31df5af669b415..00000000000000 --- a/frontend/src/components/RequiredStar.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/frontend/src/components/RoleGrantPanel/MaxRowCountSelect.vue b/frontend/src/components/RoleGrantPanel/MaxRowCountSelect.vue deleted file mode 100644 index 286112a6804be4..00000000000000 --- a/frontend/src/components/RoleGrantPanel/MaxRowCountSelect.vue +++ /dev/null @@ -1,91 +0,0 @@ - - - diff --git a/frontend/src/components/SQLReview/components/RuleConfigComponents/BooleanComponent.vue b/frontend/src/components/SQLReview/components/RuleConfigComponents/BooleanComponent.vue deleted file mode 100644 index c3d73dc1b3f11d..00000000000000 --- a/frontend/src/components/SQLReview/components/RuleConfigComponents/BooleanComponent.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - diff --git a/frontend/src/components/SQLReview/components/RuleConfigComponents/NumberComponent.vue b/frontend/src/components/SQLReview/components/RuleConfigComponents/NumberComponent.vue deleted file mode 100644 index 010c430f5c8d66..00000000000000 --- a/frontend/src/components/SQLReview/components/RuleConfigComponents/NumberComponent.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/frontend/src/components/SQLReview/components/RuleConfigComponents/StringArrayComponent.vue b/frontend/src/components/SQLReview/components/RuleConfigComponents/StringArrayComponent.vue deleted file mode 100644 index e9664b257e1aef..00000000000000 --- a/frontend/src/components/SQLReview/components/RuleConfigComponents/StringArrayComponent.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/frontend/src/components/SQLReview/components/RuleConfigComponents/StringComponent.vue b/frontend/src/components/SQLReview/components/RuleConfigComponents/StringComponent.vue deleted file mode 100644 index 5a75bfc44de8b8..00000000000000 --- a/frontend/src/components/SQLReview/components/RuleConfigComponents/StringComponent.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/frontend/src/components/SQLReview/components/RuleConfigComponents/TemplateComponent.vue b/frontend/src/components/SQLReview/components/RuleConfigComponents/TemplateComponent.vue deleted file mode 100644 index 562274606927b1..00000000000000 --- a/frontend/src/components/SQLReview/components/RuleConfigComponents/TemplateComponent.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - diff --git a/frontend/src/components/SQLReview/components/RuleConfigComponents/index.ts b/frontend/src/components/SQLReview/components/RuleConfigComponents/index.ts index 9876e572279542..eea524d655708d 100644 --- a/frontend/src/components/SQLReview/components/RuleConfigComponents/index.ts +++ b/frontend/src/components/SQLReview/components/RuleConfigComponents/index.ts @@ -1,14 +1 @@ -import BooleanComponent from "./BooleanComponent.vue"; -import NumberComponent from "./NumberComponent.vue"; -import StringArrayComponent from "./StringArrayComponent.vue"; -import StringComponent from "./StringComponent.vue"; -import TemplateComponent from "./TemplateComponent.vue"; - -export { - StringComponent, - NumberComponent, - BooleanComponent, - StringArrayComponent, - TemplateComponent, -}; export * from "./types"; diff --git a/frontend/src/components/misc/MaskSpinner.vue b/frontend/src/components/misc/MaskSpinner.vue deleted file mode 100644 index 041e98f68bd279..00000000000000 --- a/frontend/src/components/misc/MaskSpinner.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/frontend/src/components/misc/SQLUploadButton.vue b/frontend/src/components/misc/SQLUploadButton.vue deleted file mode 100644 index 819aedce20bf73..00000000000000 --- a/frontend/src/components/misc/SQLUploadButton.vue +++ /dev/null @@ -1,98 +0,0 @@ - - - diff --git a/frontend/src/components/v2/TabFilter/TabFilter.vue b/frontend/src/components/v2/TabFilter/TabFilter.vue deleted file mode 100644 index be11a5e5993889..00000000000000 --- a/frontend/src/components/v2/TabFilter/TabFilter.vue +++ /dev/null @@ -1,74 +0,0 @@ - - - diff --git a/frontend/src/components/v2/TabFilter/index.ts b/frontend/src/components/v2/TabFilter/index.ts deleted file mode 100644 index 408fdd9c60ff03..00000000000000 --- a/frontend/src/components/v2/TabFilter/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import TabFilter from "./TabFilter.vue"; - -export * from "./types"; -export { TabFilter }; diff --git a/frontend/src/components/v2/TabFilter/types.ts b/frontend/src/components/v2/TabFilter/types.ts deleted file mode 100644 index 58c3cde3074682..00000000000000 --- a/frontend/src/components/v2/TabFilter/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type TabFilterItem = { - label: string; - value: T; -}; diff --git a/frontend/src/components/v2/index.ts b/frontend/src/components/v2/index.ts index 4970cd0ce3e1f4..f792fd5f668799 100644 --- a/frontend/src/components/v2/index.ts +++ b/frontend/src/components/v2/index.ts @@ -1,5 +1,4 @@ export * from "./Select"; -export * from "./TabFilter"; export * from "./Model"; export * from "./Form"; export * from "./Button"; diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index a37d01727009a0..a7e58a09538563 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -1115,8 +1115,7 @@ "description": "Input the \"key:value\" pair then press Enter" }, "project": { - "description": "Filter by project", - "title": "Project" + "description": "Filter by project" }, "state": { "description": "Filter by state" @@ -1128,9 +1127,7 @@ "description": "Input the value then press Enter", "title": "Table" } - }, - "search": "Input title or id to search", - "self": "Advanced search" + } }, "approval-flow": { "self": "Approval flow" diff --git a/frontend/src/locales/es-ES.json b/frontend/src/locales/es-ES.json index ef253748a4480c..1bc0094e7cf25a 100644 --- a/frontend/src/locales/es-ES.json +++ b/frontend/src/locales/es-ES.json @@ -1115,8 +1115,7 @@ "description": "Ingrese el par \"clave:valor\" y luego presione Enter" }, "project": { - "description": "Filtrar por proyecto", - "title": "Proyecto" + "description": "Filtrar por proyecto" }, "state": { "description": "Filtrar por estado" @@ -1128,9 +1127,7 @@ "description": "Ingrese el valor y luego presione Enter", "title": "Tabla" } - }, - "search": "Ingrese título o id para buscar", - "self": "Búsqueda Avanzada" + } }, "approval-flow": { "self": "Flujo de aprobación" diff --git a/frontend/src/locales/ja-JP.json b/frontend/src/locales/ja-JP.json index c17cab86e0d34e..5706e18813e367 100644 --- a/frontend/src/locales/ja-JP.json +++ b/frontend/src/locales/ja-JP.json @@ -1115,8 +1115,7 @@ "description": "「キー:値」のペアを入力し、Enterを押します" }, "project": { - "description": "プロジェクトでフィルターする", - "title": "プロジェクト" + "description": "プロジェクトでフィルターする" }, "state": { "description": "状態でフィルタ" @@ -1128,9 +1127,7 @@ "description": "値を入力してEnterを押します", "title": "テーブル" } - }, - "search": "検索するにはタイトルまたはIDを入力してください", - "self": "高度な検索" + } }, "approval-flow": { "self": "承認の流れ" diff --git a/frontend/src/locales/vi-VN.json b/frontend/src/locales/vi-VN.json index ecc16ff5e2902b..11790a4fd79d65 100644 --- a/frontend/src/locales/vi-VN.json +++ b/frontend/src/locales/vi-VN.json @@ -1115,8 +1115,7 @@ "description": "Nhập cặp \"key:value\" rồi nhấn Enter" }, "project": { - "description": "Lọc theo dự án", - "title": "Dự án" + "description": "Lọc theo dự án" }, "state": { "description": "Lọc theo trạng thái" @@ -1128,9 +1127,7 @@ "description": "Nhập giá trị sau đó nhấn Enter", "title": "Bảng" } - }, - "search": "Nhập tiêu đề hoặc id để tìm kiếm", - "self": "Tìm kiếm nâng cao" + } }, "approval-flow": { "self": "Quy trình phê duyệt" diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 4bbbadd2b8a794..9c6cf4fee6d501 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -1115,8 +1115,7 @@ "description": "输入 \"key:value\" 后按下回车" }, "project": { - "description": "根据项目过滤", - "title": "项目" + "description": "根据项目过滤" }, "state": { "description": "按状态过滤" @@ -1128,9 +1127,7 @@ "description": "输入值然后按 Enter", "title": "表" } - }, - "search": "输入名称或 ID 来搜索", - "self": "高级搜索" + } }, "approval-flow": { "self": "审批流" diff --git a/frontend/src/react/no-react-to-vue-imports.test.ts b/frontend/src/react/no-react-to-vue-imports.test.ts new file mode 100644 index 00000000000000..a3de898f33db5c --- /dev/null +++ b/frontend/src/react/no-react-to-vue-imports.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; + +// Every file under frontend/src/react/ that is *.ts or *.tsx (the React layer). +// .vue files are skipped: by definition .vue is Vue-side and is allowed to import +// other .vue files. +const sources = import.meta.glob("./**/*.{ts,tsx}", { + query: "?raw", + import: "default", + eager: true, +}) as Record; + +// Mount-bridge Vue files that React code is permitted to import until Phase B +// retires the Vue app shell. Adding new entries here requires explicit review. +const allowedVueImports = new Set([ + "@/components/SessionExpiredSurfaceMount.vue", + "@/components/AgentWindowMount.vue", +]); + +const vueImportPattern = /from\s+["']([^"']+\.vue)["']/g; + +describe("React layer must not import .vue files", () => { + test("no .tsx or .ts file under frontend/src/react/ imports a .vue file", () => { + const violations: string[] = []; + for (const [file, source] of Object.entries(sources)) { + // Don't scan this guard itself (it contains .vue strings as test data + // in the allowlist above). + if (file.endsWith("/no-react-to-vue-imports.test.ts")) continue; + // Don't scan the sibling guard (same reason — it has .vue strings as + // banned-import test data). + if (file.endsWith("/no-legacy-vue-deps.test.ts")) continue; + + let match: RegExpExecArray | null; + vueImportPattern.lastIndex = 0; + while ((match = vueImportPattern.exec(source)) !== null) { + const importPath = match[1]; + if (!allowedVueImports.has(importPath)) { + violations.push(`${file}: ${importPath}`); + } + } + } + expect(violations).toEqual([]); + }); +}); From e77558acb9fbf107cb53589fe1582225aef01c44 Mon Sep 17 00:00:00 2001 From: Vincent Huang <40749774+vsai12@users.noreply.github.com> Date: Tue, 12 May 2026 03:30:58 -0700 Subject: [PATCH 026/127] =?UTF-8?q?refactor(advisor/tidb):=20migrate=20cro?= =?UTF-8?q?ss-stmt=20column-state=20pair=20to=20omni=20AST=20(Phase=201.5?= =?UTF-8?q?=20=C2=A71.5.3=20batch=2016)=20(#20322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(advisor/tidb): migrate cross-stmt column-state pair to omni AST (Phase 1.5 §1.5.3 batch 16) Two cross-statement-state advisors, both Recipe A: - advisor_column_disallow_drop_in_index (~141→106 LOC): CREATE TABLE indexes feed tables[name][col]=true (INCREMENT); ALTER ATDropColumn side-loads OriginalMetadata catalog indexes (SIDE-LOAD CATALOG + READ) and checks the dropped column against the index set. Only KEY/INDEX (omni ConstrIndex; pingcap analog ConstraintIndex) — UNIQUE/PRIMARY/FULLTEXT/SPATIAL are NOT tracked, matching pingcap-tidb scope. - advisor_column_required (~221→196 LOC, richer state machine): Five distinct per-arm mutation semantics preserved (cumulative #25 audit axis applied): * CreateTableStmt: REPLACE (initEmptyTable then addColumn) * DropTableStmt: DELETE (remove from tables map) * ATRenameColumn: READ-MODIFY-WRITE (toggle old→false / new→true) * ATAddColumn: READ-MODIFY-WRITE (lazy-init as "all required present" if table absent; mark column present if required) * ATDropColumn: READ-MODIFY-WRITE (mark required-column absent) * ATChangeColumn: equivalent to RENAME (cmd.Name=old, cmd.Column.Name=new — verified via existing advisor_column_disallow_changing_type.go pattern) Line tracking unchanged: only update line[table] on advice-triggering changes (DROP/RENAME-away from required); ADD COLUMN doesn't update line. Pre-batch protocol verified: - Step 2a (type-byte): zero hits across both files - Cumulative #19 (case-sensitivity): pre-omni uses .O throughout, no .L lowercase normalization; omni's direct strings preserve user case mechanically. NOT a regression. Pinned with case-sensitivity fixtures for both advisors. - Cumulative #25 (state-mutation per arm): each arm enumerated separately; no shared setter helper. - Cumulative #26 (UNION-root narrow-cast audit): clean — no *ast.SelectStmt narrow-casts in either file. tableState / columnSet types in utils.go are pure-Go (map[string]bool family) — engine-agnostic, reused unchanged across pingcap and omni paths. Migration count: 41 → 43 omni-migrated; 6 remaining (4 migratable + 1 Class III blocked + utils.go bridge). Co-Authored-By: Claude Opus 4.7 (1M context) * test(advisor/tidb): expand fixture pins for cross-stmt column-state pair (Phase 1.5 §1.5.3 batch 16) column_disallow_drop_in_index.yaml — two new pins: - Catalog side-load: ALTER TABLE tech_book DROP COLUMN name (no CREATE in review) fires via OriginalMetadata.ListIndexes (tech_book is pre-populated in mock catalog with index on id, name per utils_for_tests.go:80-82). Exercises the load- bearing SIDE-LOAD CATALOG path that existing CREATE-then-ALTER fixtures didn't reach (those built index info via the CREATE arm's INCREMENT path). - Case-sensitivity (cumulative #19 axis): CREATE TABLE t(A int, INDEX(A)) + ALTER DROP COLUMN a (lowercase mismatch) — rule does NOT fire. Documents that this advisor doesn't need the strings.ToLower normalization that batch 9's column_require_default needed. column_required.yaml — two new pins: - DROP-then-reCREATE (cumulative #25 axis): DROP TABLE deletes state; the subsequent CREATE on the same name REPLACES with fresh empty state. The re-incarnation is missing required columns, fires on the SECOND CREATE. - Case-sensitivity (cumulative #19 axis): a column "Creator_id" does NOT match required column "creator_id" — rule fires (case-mismatch = missing). Documents pre-omni .O behavior preserved. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(advisor/tidb): preserve ATRenameColumn unconditional line update on column_required (cumulative #27) Pingcap-tidb's pre-omni Enter had ASYMMETRIC line[table] update semantics across two superficially-similar arms: - AlterTableRenameColumn: line updated UNCONDITIONALLY (the renameColumn() return value was discarded — line updated even when neither old nor new column was in the required set). - AlterTableChangeColumn: line updated CONDITIONALLY on renameColumn() return value (only when old name was required). Externally observable on `CREATE TABLE book(id); ALTER TABLE book RENAME COLUMN x TO y`: pre-omni: advice line = 2 (the RENAME) initial batch: advice line = 1 (the CREATE) — regression this commit: advice line = 2 (matches pre-omni) The asymmetry is load-bearing — RENAME COLUMN is a syntactically explicit "user modified this table at this line" signal that the rule wants to surface even when the modification itself is advice-neutral. Initial batch 16 migration unified both arms behind the conditional pattern, regressing RENAME. Caught pre-merge by Codex P2. Fix: split ATRenameColumn to discard the renameColumn() return value and update line[table] unconditionally; ATChangeColumn arm remains conditional. Pinned with Codex repro fixture asserting line=2. NEW pre-batch audit axis added to plan-doc cumulative #27 as a refinement of #25: the per-arm state-mutation audit must extend to ALL state maps in the checker, not just the primary tables[name] map. Auxiliary state maps (line[table] for advice positioning, etc.) have their own per-arm conditionality contracts that may diverge from the primary state's. Audit must be cross-arm × cross-map (Cartesian); don't extrapolate. Four NEW pre-batch audit axes now in framework: - #23 loop-control symmetry between sibling call-sites - #24 type-assertion narrowing on interface-typed fields - #25 state-mutation semantics per arm (primary state) - #27 auxiliary state map per-arm semantics (refinement of #25) Also updated the file-level docstring's "Line tracking" section to correctly describe the per-arm line-update semantics (the prior docstring incorrectly claimed RENAME was conditional). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../advisor_column_disallow_drop_in_index.go | 173 ++++++------ .../advisor/tidb/advisor_column_required.go | 248 +++++++++++------- .../test/column_disallow_drop_in_index.yaml | 34 ++- .../advisor/tidb/test/column_required.yaml | 77 ++++++ 4 files changed, 329 insertions(+), 203 deletions(-) diff --git a/backend/plugin/advisor/tidb/advisor_column_disallow_drop_in_index.go b/backend/plugin/advisor/tidb/advisor_column_disallow_drop_in_index.go index 0aa65f8fc59d6f..4d245bad3c6118 100644 --- a/backend/plugin/advisor/tidb/advisor_column_disallow_drop_in_index.go +++ b/backend/plugin/advisor/tidb/advisor_column_disallow_drop_in_index.go @@ -1,24 +1,19 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" - "github.com/bytebase/bytebase/backend/plugin/advisor/code" - - "github.com/pingcap/tidb/pkg/parser/ast" + "github.com/bytebase/omni/tidb/ast" "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" - "github.com/bytebase/bytebase/backend/store/model" + "github.com/bytebase/bytebase/backend/plugin/advisor/code" ) var ( _ advisor.Advisor = (*ColumnDisallowDropInIndexAdvisor)(nil) - _ ast.Visitor = (*columnDisallowDropInIndexChecker)(nil) ) func init() { @@ -26,13 +21,31 @@ func init() { } // ColumnDisallowDropInIndexAdvisor is the advisor checking for disallow DROP COLUMN in index. -type ColumnDisallowDropInIndexAdvisor struct { -} - -// Check checks for disallow Drop COLUMN in index statement. +type ColumnDisallowDropInIndexAdvisor struct{} + +// Check tracks index-column membership across the reviewed statements +// (Recipe A cross-stmt state) and emits an advice when an ALTER TABLE +// DROP COLUMN targets a column that's part of an index. +// +// Per-arm state-mutation semantics (cumulative #25 audit axis): +// - CreateTableStmt: INCREMENT — populate tables[name][col]=true for +// each plain column in the table's KEY/INDEX constraints (omni +// ConstrIndex; pingcap analog was ConstraintIndex, plain non-unique +// only — UNIQUE / PRIMARY KEY / FULLTEXT / SPATIAL are NOT tracked). +// - AlterTableStmt ATDropColumn: SIDE-LOAD CATALOG + READ — populate +// tables[name] from OriginalMetadata.ListIndexes() (which reflects +// the FINAL schema state including any pre-statement indexes the +// reviewed CREATE TABLEs didn't add), then check if the dropped +// column is in the index set. Side-load is idempotent across +// multiple DROP COLUMN cmds in the same ALTER. +// +// Identifier case-sensitivity (cumulative #19): pre-omni used `.O` +// (original case) throughout; omni's direct strings preserve user case. +// `CREATE TABLE t(A INT, INDEX(A)); ALTER TABLE t DROP COLUMN a` does +// NOT fire (case-mismatched lookup misses the index set) — matches +// pingcap-tidb's pre-omni case-sensitive behavior. Pinned with fixture. func (*ColumnDisallowDropInIndexAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -41,101 +54,61 @@ func (*ColumnDisallowDropInIndexAdvisor) Check(_ context.Context, checkCtx advis if err != nil { return nil, err } - - checker := &columnDisallowDropInIndexChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - tables: make(tableState), - originalMetadata: checkCtx.OriginalMetadata, - } - - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) - } - - return checker.adviceList, nil -} - -type columnDisallowDropInIndexChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - tables tableState // the variable mean whether the column in index. - originalMetadata *model.DatabaseMetadata - line int -} - -func (checker *columnDisallowDropInIndexChecker) Enter(in ast.Node) (ast.Node, bool) { - switch node := in.(type) { - case *ast.CreateTableStmt: - checker.addIndexColumn(node) - case *ast.AlterTableStmt: - return checker.dropColumn(node) - default: - } - return in, false -} - -func (*columnDisallowDropInIndexChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true -} - -func (checker *columnDisallowDropInIndexChecker) dropColumn(in ast.Node) (ast.Node, bool) { - if node, ok := in.(*ast.AlterTableStmt); ok { - for _, spec := range node.Specs { - if spec.Tp == ast.AlterTableDropColumn { - table := node.Table.Name.O - - tableMetadata := checker.originalMetadata.GetSchemaMetadata("").GetTable(table) - if tableMetadata != nil { - if checker.tables[table] == nil { - checker.tables[table] = make(columnSet) + title := checkCtx.Rule.Type.String() + tables := make(tableState) + + var adviceList []*storepb.Advice + for _, ostmt := range stmts { + switch n := ostmt.Node.(type) { + case *ast.CreateTableStmt: + if n.Table == nil { + continue + } + tableName := n.Table.Name + if tables[tableName] == nil { + tables[tableName] = make(columnSet) + } + for _, c := range n.Constraints { + if c == nil || c.Type != ast.ConstrIndex { + continue + } + for _, col := range omniIndexColumns(c.IndexColumns) { + tables[tableName][col] = true + } + } + case *ast.AlterTableStmt: + if n.Table == nil { + continue + } + tableName := n.Table.Name + stmtLine := ostmt.FirstTokenLine() + for _, cmd := range n.Commands { + if cmd == nil || cmd.Type != ast.ATDropColumn { + continue + } + if tableMetadata := checkCtx.OriginalMetadata.GetSchemaMetadata("").GetTable(tableName); tableMetadata != nil { + if tables[tableName] == nil { + tables[tableName] = make(columnSet) } - for _, indexColumn := range tableMetadata.ListIndexes() { - for _, column := range indexColumn.GetProto().GetExpressions() { - checker.tables[table][column] = true + for _, idx := range tableMetadata.ListIndexes() { + for _, indexedCol := range idx.GetProto().GetExpressions() { + tables[tableName][indexedCol] = true } } } - - colName := spec.OldColumnName.Name.String() - if !checker.canDrop(table, colName) { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, + colName := cmd.Name + if _, isIndexCol := tables[tableName][colName]; isIndexCol { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, Code: code.DropIndexColumn.Int32(), - Title: checker.title, - Content: fmt.Sprintf("`%s`.`%s` cannot drop index column", table, colName), - StartPosition: common.ConvertANTLRLineToPosition(checker.line), + Title: title, + Content: fmt.Sprintf("`%s`.`%s` cannot drop index column", tableName, colName), + StartPosition: common.ConvertANTLRLineToPosition(stmtLine), }) } } + default: } } - return in, false -} - -func (checker *columnDisallowDropInIndexChecker) addIndexColumn(in ast.Node) { - if node, ok := in.(*ast.CreateTableStmt); ok { - for _, spec := range node.Constraints { - if spec.Tp == ast.ConstraintIndex { - for _, key := range spec.Keys { - table := node.Table.Name.O - if checker.tables[table] == nil { - checker.tables[table] = make(columnSet) - } - checker.tables[table][key.Column.Name.O] = true - } - } - } - } -} - -func (checker *columnDisallowDropInIndexChecker) canDrop(table string, column string) bool { - if _, ok := checker.tables[table][column]; ok { - return false - } - return true + return adviceList, nil } diff --git a/backend/plugin/advisor/tidb/advisor_column_required.go b/backend/plugin/advisor/tidb/advisor_column_required.go index 6cc35c69de3d9b..d96a940235162e 100644 --- a/backend/plugin/advisor/tidb/advisor_column_required.go +++ b/backend/plugin/advisor/tidb/advisor_column_required.go @@ -6,20 +6,17 @@ import ( "slices" "strings" + "github.com/bytebase/omni/tidb/ast" "github.com/pkg/errors" - "github.com/bytebase/bytebase/backend/plugin/advisor/code" - - "github.com/pingcap/tidb/pkg/parser/ast" - "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" + "github.com/bytebase/bytebase/backend/plugin/advisor/code" ) var ( _ advisor.Advisor = (*ColumnRequirementAdvisor)(nil) - _ ast.Visitor = (*columnRequirementChecker)(nil) ) func init() { @@ -27,13 +24,58 @@ func init() { } // ColumnRequirementAdvisor is the advisor checking for column requirement. -type ColumnRequirementAdvisor struct { -} +type ColumnRequirementAdvisor struct{} -// Check checks for the column requirement. +// Check tracks per-table required-column presence across the reviewed +// statements and emits a per-table advice listing any required columns +// that ended up missing. +// +// Per-arm state-mutation semantics (cumulative #25 audit axis — five +// distinct semantics across arms; preserve each independently): +// - CreateTableStmt: REPLACE — `initEmptyTable(name)` then `addColumn` +// per column in the new table. Resets prior state on re-creation. +// - DropTableStmt: DELETE — `delete(tables, name)` per dropped table. +// The table no longer appears in `generateAdviceList`. Pre-omni did +// NOT delete from the `line` map; preserved (orphaned line entries +// are harmless — generateAdviceList iterates tables, not line). +// - AlterTableStmt ATRenameColumn: READ-MODIFY-WRITE — `renameColumn` +// toggles old→false / new→true in the required-column map; lazy- +// initializes the table state as "all required present" if absent. +// - AlterTableStmt ATAddColumn: READ-MODIFY-WRITE — `addColumn` per +// added column (handles both singular `cmd.Column` and grouped +// `cmd.Columns` via `addColumnTargets`); lazy-initializes the +// table state as "all required present" if absent. NO line update +// (ADD COLUMN can only make missing required columns present; +// never triggers a new advice). +// - AlterTableStmt ATDropColumn: READ-MODIFY-WRITE — `dropColumn` +// marks the column false if it's required. +// - AlterTableStmt ATChangeColumn: equivalent to RENAME — `cmd.Name` +// is old, `cmd.Column.Name` is new (verified in +// advisor_column_disallow_changing_type.go pattern). +// +// Line tracking has per-arm semantics distinct from primary state +// (cumulative #27 — auxiliary state maps have their own conditionality +// contracts; mechanical port must audit each state map independently): +// - CreateTable: seeds line[table] unconditionally. +// - DropTable: does NOT delete from line (orphaned entries harmless; +// generateAdviceList iterates tables, not line). +// - ATRenameColumn: updates line UNCONDITIONALLY (RENAME is a more +// explicit "user modified this table at this line" signal). +// - ATAddColumn: does NOT update line (ADD can only make missing +// required columns present; never triggers new advice). +// - ATDropColumn: updates line ONLY when the dropped column was +// required (return value of dropColumn). +// - ATChangeColumn: updates line ONLY when the old name was required +// (return value of renameColumn). Asymmetric vs ATRenameColumn — +// pre-omni intentional asymmetry preserved. +// +// Identifier case-sensitivity (cumulative #19): pre-omni used `.O` +// throughout (no `.L` lowercase); omni's direct strings preserve user +// case. `CREATE TABLE t(A INT); ALTER TABLE t RENAME COLUMN a TO X` +// matches case-sensitively — pre-omni would NOT match `a` to required +// column `A`. Preserved. func (*ColumnRequirementAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - root, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -46,86 +88,94 @@ func (*ColumnRequirementAdvisor) Check(_ context.Context, checkCtx advisor.Conte if stringArrayPayload == nil { return nil, errors.New("string_array_payload is required for column required rule") } - requiredColumns := make(columnSet) - for _, column := range stringArrayPayload.List { - requiredColumns[column] = true - } - checker := &columnRequirementChecker{ - level: level, - title: checkCtx.Rule.Type.String(), + requiredColumns := newColumnSet(stringArrayPayload.List) + title := checkCtx.Rule.Type.String() + + v := &columnRequirementState{ requiredColumns: requiredColumns, tables: make(tableState), line: make(map[string]int), } - for _, stmtNode := range root { - (stmtNode).Accept(checker) - } - - return checker.generateAdviceList(), nil -} - -type columnRequirementChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - requiredColumns columnSet - tables tableState - line map[string]int -} - -// Enter implements the ast.Visitor interface. -func (v *columnRequirementChecker) Enter(in ast.Node) (ast.Node, bool) { - switch node := in.(type) { - // CREATE TABLE - case *ast.CreateTableStmt: - v.createTable(node) - // DROP TABLE - case *ast.DropTableStmt: - for _, table := range node.Tables { - delete(v.tables, table.Name.String()) - } - // ALTER TABLE - case *ast.AlterTableStmt: - table := node.Table.Name.O - for _, spec := range node.Specs { - switch spec.Tp { - // RENAME COLUMN - case ast.AlterTableRenameColumn: - v.renameColumn(table, spec.OldColumnName.Name.O, spec.NewColumnName.Name.O) - v.line[table] = node.OriginTextPosition() - // ADD COLUMNS - case ast.AlterTableAddColumns: - for _, column := range spec.NewColumns { - v.addColumn(table, column.Name.Name.O) + for _, ostmt := range stmts { + stmtLine := ostmt.FirstTokenLine() + switch n := ostmt.Node.(type) { + case *ast.CreateTableStmt: + if n.Table == nil { + continue + } + v.line[n.Table.Name] = stmtLine + v.initEmptyTable(n.Table.Name) + for _, column := range n.Columns { + if column == nil { + continue + } + v.addColumn(n.Table.Name, column.Name) + } + case *ast.DropTableStmt: + for _, table := range n.Tables { + if table != nil { + delete(v.tables, table.Name) } - // DROP COLUMN - case ast.AlterTableDropColumn: - if v.dropColumn(table, spec.OldColumnName.Name.O) { - v.line[table] = node.OriginTextPosition() + } + case *ast.AlterTableStmt: + if n.Table == nil { + continue + } + table := n.Table.Name + for _, cmd := range n.Commands { + if cmd == nil { + continue } - // CHANGE COLUMN - case ast.AlterTableChangeColumn: - if v.renameColumn(table, spec.OldColumnName.Name.O, spec.NewColumns[0].Name.Name.O) { - v.line[table] = node.OriginTextPosition() + switch cmd.Type { + case ast.ATRenameColumn: + // Cumulative #27: pre-omni updated line[table] + // UNCONDITIONALLY for RENAME COLUMN, even when + // neither old nor new is in the required set. + // CHANGE COLUMN (below) is conditional on return + // value — asymmetric pre-omni behavior preserved. + // RENAME is a more explicit "user modified this + // table at this line" signal that should mark the + // position regardless of required-column impact. + v.renameColumn(table, cmd.Name, cmd.NewName) + v.line[table] = stmtLine + case ast.ATAddColumn: + for _, column := range addColumnTargets(cmd) { + if column == nil { + continue + } + v.addColumn(table, column.Name) + } + case ast.ATDropColumn: + if v.dropColumn(table, cmd.Name) { + v.line[table] = stmtLine + } + case ast.ATChangeColumn: + if cmd.Column == nil { + continue + } + if v.renameColumn(table, cmd.Name, cmd.Column.Name) { + v.line[table] = stmtLine + } + default: } - default: } + default: } - default: } - return in, false + + return v.generateAdviceList(level, title), nil } -// Leave implements the ast.Visitor interface. -func (*columnRequirementChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true +type columnRequirementState struct { + requiredColumns columnSet + tables tableState + line map[string]int } -func (v *columnRequirementChecker) generateAdviceList() []*storepb.Advice { - // Order it cause the random iteration order in Go, see https://go.dev/blog/maps - tableList := v.tables.tableList() - for _, tableName := range tableList { +func (v *columnRequirementState) generateAdviceList(level storepb.Advice_Status, title string) []*storepb.Advice { + var adviceList []*storepb.Advice + for _, tableName := range v.tables.tableList() { table := v.tables[tableName] var missingColumns []string for column := range v.requiredColumns { @@ -134,29 +184,30 @@ func (v *columnRequirementChecker) generateAdviceList() []*storepb.Advice { } } if len(missingColumns) > 0 { - // Order it cause the random iteration order in Go, see https://go.dev/blog/maps slices.Sort(missingColumns) - v.adviceList = append(v.adviceList, &storepb.Advice{ - Status: v.level, + adviceList = append(adviceList, &storepb.Advice{ + Status: level, Code: code.NoRequiredColumn.Int32(), - Title: v.title, + Title: title, Content: fmt.Sprintf("Table `%s` requires columns: %s", tableName, strings.Join(missingColumns, ", ")), StartPosition: common.ConvertANTLRLineToPosition(v.line[tableName]), }) } } - - return v.adviceList + return adviceList } -// initEmptyTable will initialize a table without any required columns. -func (v *columnRequirementChecker) initEmptyTable(name string) columnSet { +// initEmptyTable initializes a table with no required columns present. +func (v *columnRequirementState) initEmptyTable(name string) columnSet { v.tables[name] = make(columnSet) return v.tables[name] } -// initFullTable will initialize a table with all required columns. -func (v *columnRequirementChecker) initFullTable(name string) columnSet { +// initFullTable initializes a table with all required columns present. +// Used for "we don't retrospectively check": when ALTER targets a table +// we haven't seen CREATE for, we assume it had all required columns, +// so only modifications surfaced in this review affect the verdict. +func (v *columnRequirementState) initFullTable(name string) columnSet { table := v.initEmptyTable(name) for column := range v.requiredColumns { table[column] = true @@ -164,7 +215,11 @@ func (v *columnRequirementChecker) initFullTable(name string) columnSet { return table } -func (v *columnRequirementChecker) renameColumn(table string, oldColumn string, newColumn string) bool { +// renameColumn marks the old name absent and the new name present, if +// either is in the required set. Returns true if the OLD name was +// required (meaning the rename could surface a new missing-required; +// caller updates the line). +func (v *columnRequirementState) renameColumn(table, oldColumn, newColumn string) bool { _, oldNeed := v.requiredColumns[oldColumn] _, newNeed := v.requiredColumns[newColumn] if !oldNeed && !newNeed { @@ -172,8 +227,6 @@ func (v *columnRequirementChecker) renameColumn(table string, oldColumn string, } t, ok := v.tables[table] if !ok { - // We do not retrospectively check. - // So we assume it contains all required columns. t = v.initFullTable(table) } if oldNeed { @@ -185,37 +238,28 @@ func (v *columnRequirementChecker) renameColumn(table string, oldColumn string, return oldNeed } -func (v *columnRequirementChecker) dropColumn(table string, column string) bool { +// dropColumn marks the column absent if it's required. Returns true +// when the dropped column was required. +func (v *columnRequirementState) dropColumn(table, column string) bool { if _, ok := v.requiredColumns[column]; !ok { return false } t, ok := v.tables[table] if !ok { - // We do not retrospectively check. - // So we assume it contains all required columns. t = v.initFullTable(table) } t[column] = false return true } -func (v *columnRequirementChecker) addColumn(table string, column string) { +// addColumn marks the column present if it's required. +func (v *columnRequirementState) addColumn(table, column string) { if _, ok := v.requiredColumns[column]; !ok { return } if t, ok := v.tables[table]; !ok { - // We do not retrospectively check. - // So we assume it contains all required columns. v.initFullTable(table) } else { t[column] = true } } - -func (v *columnRequirementChecker) createTable(node *ast.CreateTableStmt) { - v.line[node.Table.Name.O] = node.OriginTextPosition() - v.initEmptyTable(node.Table.Name.O) - for _, column := range node.Cols { - v.addColumn(node.Table.Name.O, column.Name.Name.O) - } -} diff --git a/backend/plugin/advisor/tidb/test/column_disallow_drop_in_index.yaml b/backend/plugin/advisor/tidb/test/column_disallow_drop_in_index.yaml index 83de8a462c0661..56ca4405183d78 100644 --- a/backend/plugin/advisor/tidb/test/column_disallow_drop_in_index.yaml +++ b/backend/plugin/advisor/tidb/test/column_disallow_drop_in_index.yaml @@ -54,4 +54,36 @@ startposition: line: 3 column: 0 - endposition: null \ No newline at end of file + endposition: null +# Batch 16: catalog side-load pin — ALTER DROP COLUMN on a table NOT +# created in the reviewed statements should consult OriginalMetadata +# for index membership. tech_book is pre-populated in the mock catalog +# with an index on (id, name) (utils_for_tests.go:80-82); dropping +# either column should fire via the SIDE-LOAD CATALOG path. This pin +# exercises the load-bearing path that existing CREATE-then-ALTER +# fixtures didn't reach (those built index info via the CREATE arm). +- statement: ALTER TABLE tech_book DROP COLUMN name + changeType: 1 + want: + - status: 2 + code: 424 + title: COLUMN_DISALLOW_DROP_IN_INDEX + content: '`tech_book`.`name` cannot drop index column' + startposition: + line: 1 + column: 0 + endposition: null +# Batch 16: case-sensitivity pin (cumulative #19 territory) — pre-omni +# pingcap-tidb used .O (original case) throughout, with no .L +# lowercase normalization. CREATE TABLE t(A INT, INDEX(A)) stores +# "A" in the index set; ALTER TABLE t DROP COLUMN a looks up "a" +# which DOES NOT match. Rule does NOT fire — preserves pingcap-tidb +# case-sensitive behavior. Omni's direct strings also preserve user +# case, so the omni port matches mechanically. NOT a regression. Pin +# documents that this advisor doesn't need the strings.ToLower +# normalization that batch 9's column_require_default needed (which +# used .L explicitly in pre-omni source). +- statement: |- + CREATE TABLE t(A int, INDEX idx_A(A)); + ALTER TABLE t DROP COLUMN a + changeType: 1 \ No newline at end of file diff --git a/backend/plugin/advisor/tidb/test/column_required.yaml b/backend/plugin/advisor/tidb/test/column_required.yaml index 69c2d993335a3e..9975f98f6d5c33 100644 --- a/backend/plugin/advisor/tidb/test/column_required.yaml +++ b/backend/plugin/advisor/tidb/test/column_required.yaml @@ -162,3 +162,80 @@ updated_ts timestamp); DROP TABLE book; changeType: 1 +# Batch 16: DROP-then-reCREATE pin (cumulative #25 axis) — DROP TABLE +# removes the table from state; the subsequent CREATE TABLE on the +# same name REPLACES with a fresh empty state (initEmptyTable + +# per-column addColumn). The re-CREATE is missing required columns, +# so the rule fires on the SECOND incarnation. Distinct from +# statement_merge_alter_table's CREATE-resets pattern but same audit +# axis: per-arm state-mutation semantics (DELETE then REPLACE). +- statement: |- + CREATE TABLE book( + id int, + creator_id int, + created_ts timestamp, + updater_id int, + updated_ts timestamp); + DROP TABLE book; + CREATE TABLE book(id int); + changeType: 1 + want: + - status: 2 + code: 401 + title: COLUMN_REQUIRED + content: 'Table `book` requires columns: created_ts, creator_id, updated_ts, updater_id' + startposition: + line: 8 + column: 0 + endposition: null +# Batch 16: case-sensitivity pin (cumulative #19 territory) — pre-omni +# pingcap used .O throughout (no .L). `requiredColumns` is keyed by +# the rule's configured strings (e.g. "creator_id" exactly). A column +# named "Creator_id" in the user's CREATE TABLE would NOT match +# "creator_id" in the required set — rule fires (case-mismatch = +# missing). Omni preserves user case in column.Name; matches +# pingcap-tidb behavior. NOT a regression. +- statement: |- + CREATE TABLE book( + id int, + Creator_id int, + created_ts timestamp, + updater_id int, + updated_ts timestamp); + changeType: 1 + want: + - status: 2 + code: 401 + title: COLUMN_REQUIRED + content: 'Table `book` requires columns: creator_id' + startposition: + line: 1 + column: 0 + endposition: null +# Cumulative #27 pin (batch 16, Codex P2 catch pre-merge): pre-omni +# ATRenameColumn arm UNCONDITIONALLY updated line[table] — even when +# the rename touched no required column. CHANGE COLUMN is conditional +# (only on advice-relevant rename). Initial batch 16 migration unified +# both arms behind the conditional pattern, regressing RENAME's +# unconditional line update. Externally observable: for `CREATE TABLE +# book(id); ALTER TABLE book RENAME COLUMN x TO y;` the advice +# StartPosition was line 1 (CREATE) on the regressed branch instead +# of line 2 (RENAME) on pre-omni. Fix: split the line-update from the +# renameColumn() return-value check on ATRenameColumn only; CHANGE +# COLUMN remains conditional. Cumulative #27 documents the auxiliary- +# state-map audit refinement of #25: each state map (primary `tables` +# AND auxiliary `line`) has its own per-arm conditionality contract; +# the audit must enumerate cross-arm × cross-map (Cartesian). +- statement: |- + CREATE TABLE book(id int); + ALTER TABLE book RENAME COLUMN x TO y; + changeType: 1 + want: + - status: 2 + code: 401 + title: COLUMN_REQUIRED + content: 'Table `book` requires columns: created_ts, creator_id, updated_ts, updater_id' + startposition: + line: 2 + column: 0 + endposition: null From 70b74f7f7ce89682b2d091e5e697cc662c745836 Mon Sep 17 00:00:00 2001 From: Vincent Huang <40749774+vsai12@users.noreply.github.com> Date: Tue, 12 May 2026 04:02:08 -0700 Subject: [PATCH 027/127] =?UTF-8?q?refactor(advisor/tidb):=20migrate=20ind?= =?UTF-8?q?ex=20key-number=20+=20total-number=20pair=20to=20omni=20AST=20+?= =?UTF-8?q?=20cumulative=20#28=20(Phase=201.5=20=C2=A71.5.3=20batch=2017)?= =?UTF-8?q?=20(#20323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(advisor/tidb): migrate index key-number + total-number pair to omni AST + cumulative #28 (Phase 1.5 §1.5.3 batch 17) Two index-family advisors, both Recipe A: - advisor_index_key_number_limit: per-statement constraint key count check across CreateTable / CreateIndex / AlterTable arms. No cross-stmt state. Uses file-local omniIndexKeyCount(c) helper: len(IndexColumns) for INDEX/PK/UNIQUE; len(Columns) for FOREIGN KEY (which has empty IndexColumns, verified empirically). - advisor_index_total_number_limit: cross-stmt lineForTable aggregator gating FinalMetadata catalog lookup for the final per-table index count. Per-arm conditional touches: * CreateTable / CreateIndex: UNCONDITIONAL line update * ATAddColumn: conditional via omniColumnCreatesIndex (any column in the grouped form with inline PK/UNIQUE) * ATAddConstraint: conditional via omniConstraintCreatesIndex (PK / UNIQUE / INDEX / FULLTEXT; FK / CHECK / SPATIAL NOT tracked, matching pre-omni scope) * ATChange/ATModifyColumn: conditional via omniColumnCreatesIndex File-local helpers (single-use, not promoted to utils.go): - omniIndexKeyCount: keys/columns count by constraint type - omniConstraintCreatesIndex: PK/UNIQUE/INDEX/FULLTEXT predicate - omniColumnCreatesIndex: column-level ColConstrPrimaryKey/Unique check on column.Constraints - omniConstraintAdviceName: c.Name with "PRIMARY" fallback for empty-name PK (cumulative #28) Cumulative coverage: - #2 unique-trio: omni ConstrUnique unifies pingcap's ConstraintUniq + UniqKey + UniqIndex. Verified empirically across all 3 forms. Single arm covers all mechanically. Pinned with UNIQUE INDEX fixture. - #2 key/index unification: omni ConstrIndex unifies pingcap's ConstraintKey + ConstraintIndex. Verified empirically (parsing `KEY k(a)` vs `INDEX idx(a)` both yield Type=ConstrIndex). - #28 (NEW): pingcap accepted non-standard `PRIMARY KEY name (cols)` extension and captured `name` as constraint.Name. Omni follows standard MySQL grammar (no name on PRIMARY KEY) and drops the identifier. Advisor falls back to "PRIMARY" (MySQL canonical name) for empty-name PK to avoid ugly empty-backticks. NOT a regression; documented as parser-level grammar-acceptance divergence (class b — omni normalizes away pingcap-captured state). NEW pre-batch audit axis: when advice content embeds parser-extracted identifiers, audit grammar-acceptance differential. Migration count: 43 → 45 omni-migrated; 4 remaining (2 migratable + 1 Class III blocked + utils.go bridge). Co-Authored-By: Claude Opus 4.7 (1M context) * test(advisor/tidb): expand fixture pins for index family pair (Phase 1.5 §1.5.3 batch 17) index_key_number_limit.yaml — two changes: - Cumulative #28 fixture: `CREATE TABLE t(..., primary key pk (cols))` now expects "PRIMARY" in advice (was "pk" pre-omni). Pingcap accepted the non-standard PRIMARY KEY name syntax; omni follows standard MySQL grammar and drops the name; advisor falls back to "PRIMARY" canonical. - Cumulative #2 unique-trio coverage pin: `UNIQUE INDEX ui_all` exercises the third pingcap UNIQUE variant (the existing fixture already covered UNIQUE KEY; bare UNIQUE not yet pinned). Omni unifies all 3 under ConstrUnique; the single-arm port handles all. index_total_number_limit.yaml — two new touch-path pins: - ALTER TABLE ADD UNIQUE KEY on tech_book: exercises ATAddConstraint arm with omni ConstrUnique. Documents the touch path (lineForTable registration) without depending on final catalog count exceeding maximum. - ALTER TABLE ADD COLUMN with inline PRIMARY KEY: exercises ATAddColumn arm's omniColumnCreatesIndex check on column.Constraints[*].Type==ColConstrPrimaryKey. Cumulative #2 vicinity: pre-omni used ColumnOptionPrimaryKey on the column's Options list; omni represents this on column.Constraints (typed list with ColConstrPrimaryKey). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(advisor/tidb): apply dual-arm ATAddConstraint+ATAddIndex sibling-parity convention (cumulative #17) Peer review caught that batch 17's two new advisors broke the established `case ATAddConstraint, ATAddIndex:` sibling-parity convention (batch 4 naming-trio, batch 8 index spine helper, and the recommendation explicitly framed in cumulative #17). Practical impact today: zero — tidb omni's parser empirically emits only ATAddConstraint for all `ALTER TABLE ADD ...` forms; ATAddIndex is reserved in the enum but never produced. The dual-arm convention is over-defensive on the current grammar but cost-free and forward- compat against potential grammar evolution that may split bare ADD INDEX from CONSTRAINT-named ADD INDEX, matching the mysql omni shape that cumulative #8 originally described. Applied to both new advisors: - index_key_number_limit: filter changed from cmd.Type != ast.ATAddConstraint to: cmd.Type != ast.ATAddConstraint && cmd.Type != ast.ATAddIndex - index_total_number_limit: case-arm changed from case ast.ATAddConstraint: to: case ast.ATAddConstraint, ast.ATAddIndex: Brief inline comment in each site referencing cumulative #17. No fixture pins added — omni doesn't emit ATAddIndex today, so no observable behavior change to pin. The convention adherence is for future-compat only; if/when grammar evolves to emit ATAddIndex, existing fixtures (named CONSTRAINT-prefixed ADD ...) will catch regressions. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../tidb/advisor_index_key_number_limit.go | 215 ++++++++------- .../tidb/advisor_index_total_number_limit.go | 254 +++++++++--------- .../tidb/test/index_key_number_limit.yaml | 28 +- .../tidb/test/index_total_number_limit.yaml | 19 ++ 4 files changed, 304 insertions(+), 212 deletions(-) diff --git a/backend/plugin/advisor/tidb/advisor_index_key_number_limit.go b/backend/plugin/advisor/tidb/advisor_index_key_number_limit.go index 6584d39cae65ce..f88e1da520e41b 100644 --- a/backend/plugin/advisor/tidb/advisor_index_key_number_limit.go +++ b/backend/plugin/advisor/tidb/advisor_index_key_number_limit.go @@ -1,39 +1,45 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" + "github.com/bytebase/omni/tidb/ast" "github.com/pkg/errors" - "github.com/bytebase/bytebase/backend/plugin/advisor/code" - - "github.com/pingcap/tidb/pkg/parser/ast" - "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" + "github.com/bytebase/bytebase/backend/plugin/advisor/code" ) var ( _ advisor.Advisor = (*IndexKeyNumberLimitAdvisor)(nil) - _ ast.Visitor = (*indexKeyNumberLimitChecker)(nil) ) func init() { advisor.Register(storepb.Engine_TIDB, storepb.SQLReviewRule_INDEX_KEY_NUMBER_LIMIT, &IndexKeyNumberLimitAdvisor{}) } -// IndexKeyNumberLimitAdvisor is the advisor checking for index key number limit. -type IndexKeyNumberLimitAdvisor struct { -} - -// Check checks for index key number limit. +// IndexKeyNumberLimitAdvisor flags index/constraint declarations whose +// number of key columns exceeds the configured maximum. +type IndexKeyNumberLimitAdvisor struct{} + +// Check fires on per-constraint key counts in CREATE TABLE, CREATE +// INDEX, and ALTER TABLE ADD CONSTRAINT. No cross-stmt state. Recipe A. +// +// Cumulative #2 coverage: pingcap-tidb's pre-omni indexKeyNumber() +// helper handled `ConstraintUniq`, `ConstraintUniqKey`, and +// `ConstraintUniqIndex` as three distinct enum values. Omni unifies +// all three under `ConstrUnique` (verified empirically: parsing +// `UNIQUE(a)`, `UNIQUE KEY uk(a)`, `UNIQUE INDEX ui(a)` all yields +// `Type=ConstrUnique`). The omni port matches the single arm and +// covers all three forms mechanically — NOT a regression. +// +// Cumulative #19 (case-sensitivity): pre-omni used `.O` throughout +// (no `.L`). Omni preserves user case via direct strings. Mechanical. func (*IndexKeyNumberLimitAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -46,98 +52,127 @@ func (*IndexKeyNumberLimitAdvisor) Check(_ context.Context, checkCtx advisor.Con if numberPayload == nil { return nil, errors.New("number_payload is required for index key number limit rule") } - checker := &indexKeyNumberLimitChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - max: int(numberPayload.Number), - } - - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + maximum := int(numberPayload.Number) + if maximum <= 0 { + return nil, nil } + title := checkCtx.Rule.Type.String() - return checker.adviceList, nil -} - -type indexKeyNumberLimitChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int - max int -} - -type indexData struct { - table string - index string - line int -} - -// Enter implements the ast.Visitor interface. -func (checker *indexKeyNumberLimitChecker) Enter(in ast.Node) (ast.Node, bool) { - var indexList []indexData - - appendIndexItem := func(table, index string, line int) { - indexList = append(indexList, indexData{ - table: table, - index: index, - line: line, - }) + type violation struct { + table string + index string + line int } + var hits []violation - switch node := in.(type) { - case *ast.CreateTableStmt: - for _, constraint := range node.Constraints { - if checker.max > 0 && indexKeyNumber(constraint) > checker.max { - appendIndexItem(node.Table.Name.O, constraint.Name, constraint.OriginTextPosition()) + for _, ostmt := range stmts { + switch n := ostmt.Node.(type) { + case *ast.CreateTableStmt: + if n.Table == nil { + continue } - } - case *ast.CreateIndexStmt: - if checker.max > 0 && len(node.IndexPartSpecifications) > checker.max { - appendIndexItem(node.Table.Name.O, node.IndexName, checker.line) - } - case *ast.AlterTableStmt: - for _, spec := range node.Specs { - if spec.Tp == ast.AlterTableAddConstraint { - if checker.max > 0 && indexKeyNumber(spec.Constraint) > checker.max { - appendIndexItem(node.Table.Name.O, spec.Constraint.Name, checker.line) + for _, c := range n.Constraints { + if c == nil { + continue + } + if omniIndexKeyCount(c) > maximum { + hits = append(hits, violation{ + table: n.Table.Name, + index: omniConstraintAdviceName(c), + line: ostmt.AbsoluteLine(c.Loc.Start), + }) + } + } + case *ast.CreateIndexStmt: + if n.Table == nil { + continue + } + if len(n.Columns) > maximum { + hits = append(hits, violation{ + table: n.Table.Name, + index: n.IndexName, + line: ostmt.FirstTokenLine(), + }) + } + case *ast.AlterTableStmt: + if n.Table == nil { + continue + } + stmtLine := ostmt.FirstTokenLine() + for _, cmd := range n.Commands { + if cmd == nil || cmd.Constraint == nil { + continue + } + // Cumulative #17 sibling-parity convention: tidb omni + // emits only ATAddConstraint for all `ALTER TABLE ADD + // ...` forms today, but the dual arm is the recommended + // convention (batch 4 naming-trio + batch 8 index spine + // + utils.go collectIndexFamilyAlterTable) for forward- + // compat against grammar evolution that may start + // emitting ATAddIndex. + if cmd.Type != ast.ATAddConstraint && cmd.Type != ast.ATAddIndex { + continue + } + if omniIndexKeyCount(cmd.Constraint) > maximum { + hits = append(hits, violation{ + table: n.Table.Name, + index: omniConstraintAdviceName(cmd.Constraint), + line: stmtLine, + }) } } + default: } - default: } - for _, index := range indexList { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, + adviceList := make([]*storepb.Advice, 0, len(hits)) + for _, h := range hits { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, Code: code.IndexKeyNumberExceedsLimit.Int32(), - Title: checker.title, - Content: fmt.Sprintf("The number of index `%s` in table `%s` should be not greater than %d", index.index, index.table, checker.max), - StartPosition: common.ConvertANTLRLineToPosition(index.line), + Title: title, + Content: fmt.Sprintf("The number of index `%s` in table `%s` should be not greater than %d", h.index, h.table, maximum), + StartPosition: common.ConvertANTLRLineToPosition(h.line), }) } - - return in, false + return adviceList, nil } -// Leave implements the ast.Visitor interface. -func (*indexKeyNumberLimitChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true -} - -func indexKeyNumber(constraint *ast.Constraint) int { - switch constraint.Tp { - case ast.ConstraintIndex, - ast.ConstraintPrimaryKey, - ast.ConstraintUniq, - ast.ConstraintUniqKey, - ast.ConstraintUniqIndex, - ast.ConstraintForeignKey: - return len(constraint.Keys) +// omniIndexKeyCount returns the number of key columns declared by the +// given constraint. INDEX/PK/UNIQUE store keys in `IndexColumns`; +// FOREIGN KEY stores its local columns in `Columns []string` with +// `IndexColumns` empty (verified empirically). +func omniIndexKeyCount(c *ast.Constraint) int { + if c == nil { + return 0 + } + switch c.Type { + case ast.ConstrIndex, ast.ConstrPrimaryKey, ast.ConstrUnique: + return len(c.IndexColumns) + case ast.ConstrForeignKey: + return len(c.Columns) default: return 0 } } + +// omniConstraintAdviceName returns the constraint name suitable for +// embedding in advice content. Falls back to "PRIMARY" for unnamed +// PRIMARY KEY constraints (cumulative #28: pingcap-tidb accepted the +// non-standard `PRIMARY KEY index_name (cols)` extension and captured +// the index_name; omni follows standard MySQL grammar where PRIMARY +// KEY doesn't accept an index_name and silently drops it. "PRIMARY" +// is MySQL's canonical internal name for the primary key — better UX +// than the empty backticks the raw `c.Name` would produce). +func omniConstraintAdviceName(c *ast.Constraint) string { + if c == nil { + return "" + } + if c.Name != "" { + return c.Name + } + if c.Type == ast.ConstrPrimaryKey { + return "PRIMARY" + } + return "" +} diff --git a/backend/plugin/advisor/tidb/advisor_index_total_number_limit.go b/backend/plugin/advisor/tidb/advisor_index_total_number_limit.go index e1c1a4ec1dafc8..8baa6a65ceccff 100644 --- a/backend/plugin/advisor/tidb/advisor_index_total_number_limit.go +++ b/backend/plugin/advisor/tidb/advisor_index_total_number_limit.go @@ -1,41 +1,61 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" "slices" + "github.com/bytebase/omni/tidb/ast" "github.com/pkg/errors" - "github.com/bytebase/bytebase/backend/plugin/advisor/code" - - "github.com/pingcap/tidb/pkg/parser/ast" - "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" - "github.com/bytebase/bytebase/backend/store/model" + "github.com/bytebase/bytebase/backend/plugin/advisor/code" ) var ( _ advisor.Advisor = (*IndexTotalNumberLimitAdvisor)(nil) - _ ast.Visitor = (*indexTotalNumberLimitChecker)(nil) ) func init() { advisor.Register(storepb.Engine_TIDB, storepb.SQLReviewRule_INDEX_TOTAL_NUMBER_LIMIT, &IndexTotalNumberLimitAdvisor{}) } -// IndexTotalNumberLimitAdvisor is the advisor checking for index total number limit. -type IndexTotalNumberLimitAdvisor struct { -} - -// Check checks for index total number limit. +// IndexTotalNumberLimitAdvisor flags tables whose total index count +// (read from FinalMetadata) exceeds the configured maximum, gated on +// the reviewed statements actually touching the table with an +// index-creating operation. +type IndexTotalNumberLimitAdvisor struct{} + +// Check tracks which tables had an index-creating operation across +// the reviewed statements (Recipe A; cross-stmt `lineForTable` +// aggregator), then for each such table queries FinalMetadata for +// the final index count. +// +// Per-arm state-mutation semantics (cumulative #25/#27 audit): +// - CreateTableStmt: lineForTable[name] = stmtLine (UNCONDITIONAL — +// creates the table itself; counts as a touch). +// - CreateIndexStmt: lineForTable[name] = stmtLine (UNCONDITIONAL). +// - ATAddColumn (inner loop over columns): if any new column has an +// inline PRIMARY KEY or UNIQUE constraint → record and break +// (FIRST-violating-column wins; subsequent index-creating columns +// in the same grouped form don't add additional touches; pre-omni +// `break` after `createIndex(column)` returned true preserved). +// - ATAddConstraint: if constraint creates an index → record +// (conditional; ConstrIndex / ConstrPrimaryKey / ConstrUnique / +// ConstrFulltextIndex are index-creating; FK / Check / Spatial +// are NOT, matching pre-omni scope). +// - ATChangeColumn / ATModifyColumn: if new column def declares +// inline PRIMARY KEY or UNIQUE → record (conditional). +// +// Cumulative #2 coverage: pingcap's ConstraintUniq / ConstraintUniqKey +// / ConstraintUniqIndex (3 distinct) unify under omni `ConstrUnique`; +// pingcap's `ConstraintKey` + `ConstraintIndex` unify under omni +// `ConstrIndex` (verified empirically). Single-arm port covers all +// 6 pingcap constraint forms mechanically. func (*IndexTotalNumberLimitAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -48,139 +68,131 @@ func (*IndexTotalNumberLimitAdvisor) Check(_ context.Context, checkCtx advisor.C if numberPayload == nil { return nil, errors.New("number_payload is required for index total number limit rule") } - checker := &indexTotalNumberLimitChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - max: int(numberPayload.Number), - lineForTable: make(map[string]int), - finalMetadata: checkCtx.FinalMetadata, - } - - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + maximum := int(numberPayload.Number) + title := checkCtx.Rule.Type.String() + + lineForTable := make(map[string]int) + for _, ostmt := range stmts { + stmtLine := ostmt.FirstTokenLine() + switch n := ostmt.Node.(type) { + case *ast.CreateTableStmt: + if n.Table != nil { + lineForTable[n.Table.Name] = stmtLine + } + case *ast.CreateIndexStmt: + if n.Table != nil { + lineForTable[n.Table.Name] = stmtLine + } + case *ast.AlterTableStmt: + if n.Table == nil { + continue + } + tableName := n.Table.Name + for _, cmd := range n.Commands { + if cmd == nil { + continue + } + switch cmd.Type { + case ast.ATAddColumn: + if slices.ContainsFunc(addColumnTargets(cmd), omniColumnCreatesIndex) { + lineForTable[tableName] = stmtLine + } + case ast.ATAddConstraint, ast.ATAddIndex: + // Cumulative #17 sibling-parity convention: dual + // arm preserved for forward-compat per established + // pattern in utils.go collectIndexFamilyAlterTable. + if omniConstraintCreatesIndex(cmd.Constraint) { + lineForTable[tableName] = stmtLine + } + case ast.ATChangeColumn, ast.ATModifyColumn: + if omniColumnCreatesIndex(cmd.Column) { + lineForTable[tableName] = stmtLine + } + default: + } + } + default: + } } - return checker.generateAdvice(), nil -} - -type indexTotalNumberLimitChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int - max int - lineForTable map[string]int - finalMetadata *model.DatabaseMetadata -} - -func (checker *indexTotalNumberLimitChecker) generateAdvice() []*storepb.Advice { - type tableName struct { + type tableEntry struct { name string line int } - var tableList []tableName - - for k, v := range checker.lineForTable { - tableList = append(tableList, tableName{ - name: k, - line: v, - }) + tableList := make([]tableEntry, 0, len(lineForTable)) + for name, line := range lineForTable { + tableList = append(tableList, tableEntry{name: name, line: line}) } - slices.SortFunc(tableList, func(i, j tableName) int { - if i.line < j.line { + slices.SortFunc(tableList, func(i, j tableEntry) int { + switch { + case i.line < j.line: return -1 - } - if i.line > j.line { + case i.line > j.line: return 1 + default: + return 0 } - return 0 }) - for _, table := range tableList { - schema := checker.finalMetadata.GetSchemaMetadata("") + var adviceList []*storepb.Advice + for _, t := range tableList { + schema := checkCtx.FinalMetadata.GetSchemaMetadata("") if schema == nil { continue } - tableInfo := schema.GetTable(table.name) - if tableInfo != nil && len(tableInfo.GetProto().Indexes) > checker.max { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, + tableInfo := schema.GetTable(t.name) + if tableInfo == nil { + continue + } + if count := len(tableInfo.GetProto().Indexes); count > maximum { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, Code: code.IndexCountExceedsLimit.Int32(), - Title: checker.title, - Content: fmt.Sprintf("The count of index in table `%s` should be no more than %d, but found %d", table.name, checker.max, len(tableInfo.GetProto().Indexes)), - StartPosition: common.ConvertANTLRLineToPosition(table.line), + Title: title, + Content: fmt.Sprintf("The count of index in table `%s` should be no more than %d, but found %d", t.name, maximum, count), + StartPosition: common.ConvertANTLRLineToPosition(t.line), }) } } - - return checker.adviceList + return adviceList, nil } -// Enter implements the ast.Visitor interface. -func (checker *indexTotalNumberLimitChecker) Enter(in ast.Node) (ast.Node, bool) { - switch node := in.(type) { - case *ast.CreateTableStmt: - checker.lineForTable[node.Table.Name.O] = node.OriginTextPosition() - case *ast.AlterTableStmt: - for _, spec := range node.Specs { - switch spec.Tp { - case ast.AlterTableAddColumns: - for _, column := range spec.NewColumns { - if createIndex(column) { - checker.lineForTable[node.Table.Name.O] = node.OriginTextPosition() - break - } - } - case ast.AlterTableAddConstraint: - if createIndex(spec.Constraint) { - checker.lineForTable[node.Table.Name.O] = node.OriginTextPosition() - } - case ast.AlterTableChangeColumn, ast.AlterTableModifyColumn: - if createIndex(spec.NewColumns[0]) { - checker.lineForTable[node.Table.Name.O] = node.OriginTextPosition() - } - default: - } - } - case *ast.CreateIndexStmt: - checker.lineForTable[node.Table.Name.O] = node.OriginTextPosition() +// omniConstraintCreatesIndex reports whether the given table-level +// constraint declares a new index. Mirrors pre-omni `createIndex(*ast.Constraint)`: +// PRIMARY KEY / UNIQUE (all 3 pingcap variants unified to ConstrUnique) +// / KEY+INDEX (both unified to ConstrIndex) / FULLTEXT KEY are +// index-creating. FOREIGN KEY and CHECK are NOT (pre-omni explicitly +// excluded). SPATIAL was also NOT in pre-omni's list — preserved. +func omniConstraintCreatesIndex(c *ast.Constraint) bool { + if c == nil { + return false + } + switch c.Type { + case ast.ConstrPrimaryKey, ast.ConstrUnique, ast.ConstrIndex, ast.ConstrFulltextIndex: + return true default: + return false } - - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*indexTotalNumberLimitChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true } -func createIndex(in ast.Node) bool { - switch node := in.(type) { - case *ast.ColumnDef: - for _, option := range node.Options { - switch option.Tp { - case ast.ColumnOptionPrimaryKey, ast.ColumnOptionUniqKey: - return true - default: - } +// omniColumnCreatesIndex reports whether the given column definition +// declares an inline PRIMARY KEY or UNIQUE constraint, which creates +// an index implicitly. Mirrors pre-omni's `createIndex(*ast.ColumnDef)` +// arm checking `ColumnOptionPrimaryKey` / `ColumnOptionUniqKey`. Omni +// represents column-level constraints as `column.Constraints +// []*ColumnConstraint` with `ColConstrPrimaryKey` / `ColConstrUnique` +// type values (verified empirically against parsenodes.go:404-412). +func omniColumnCreatesIndex(col *ast.ColumnDef) bool { + if col == nil { + return false + } + for _, c := range col.Constraints { + if c == nil { + continue } - case *ast.Constraint: - switch node.Tp { - case ast.ConstraintPrimaryKey, - ast.ConstraintUniq, - ast.ConstraintUniqKey, - ast.ConstraintUniqIndex, - ast.ConstraintKey, - ast.ConstraintIndex, - ast.ConstraintFulltext: + if c.Type == ast.ColConstrPrimaryKey || c.Type == ast.ColConstrUnique { return true - default: } - default: } return false } diff --git a/backend/plugin/advisor/tidb/test/index_key_number_limit.yaml b/backend/plugin/advisor/tidb/test/index_key_number_limit.yaml index e5ef57e9768625..440a2ea920ac56 100644 --- a/backend/plugin/advisor/tidb/test/index_key_number_limit.yaml +++ b/backend/plugin/advisor/tidb/test/index_key_number_limit.yaml @@ -1,5 +1,11 @@ - statement: CREATE TABLE t(a int, b int, primary key (a, b)) changeType: 1 +# Cumulative #28: pingcap-tidb accepted `PRIMARY KEY pk (cols)` (non- +# standard extension) and captured "pk" as the constraint name. Omni +# follows standard MySQL grammar and silently drops the index_name on +# PRIMARY KEY (it's not in the standard syntax). The advisor falls +# back to "PRIMARY" (MySQL canonical name) when c.Name is empty for +# ConstrPrimaryKey — better UX than empty backticks. - statement: |- CREATE TABLE t( a int, @@ -10,7 +16,7 @@ - status: 2 code: 802 title: INDEX_KEY_NUMBER_LIMIT - content: The number of index `pk` in table `t` should be not greater than 5 + content: The number of index `PRIMARY` in table `t` should be not greater than 5 startposition: line: 4 column: 0 @@ -54,3 +60,23 @@ line: 2 column: 0 endposition: null +# Cumulative #2 unique-trio coverage pin: pingcap had ConstraintUniq / +# ConstraintUniqKey / ConstraintUniqIndex as 3 distinct enums; omni +# unifies all 3 under ConstrUnique (verified empirically: parsing +# UNIQUE(...) / UNIQUE KEY n(...) / UNIQUE INDEX n(...) all yields +# Type=ConstrUnique). The single-arm omni port covers all 3 forms +# mechanically. UNIQUE INDEX form here exercises the third variant +# that earlier fixtures didn't directly pin. +- statement: |- + CREATE TABLE t(a int, b int, c int, d int, e int, f int, g int, + UNIQUE INDEX ui_all (a, b, c, d, e, f, g)) + changeType: 1 + want: + - status: 2 + code: 802 + title: INDEX_KEY_NUMBER_LIMIT + content: The number of index `ui_all` in table `t` should be not greater than 5 + startposition: + line: 2 + column: 0 + endposition: null diff --git a/backend/plugin/advisor/tidb/test/index_total_number_limit.yaml b/backend/plugin/advisor/tidb/test/index_total_number_limit.yaml index 1006c21f6cec0a..b9174ed664604e 100644 --- a/backend/plugin/advisor/tidb/test/index_total_number_limit.yaml +++ b/backend/plugin/advisor/tidb/test/index_total_number_limit.yaml @@ -55,3 +55,22 @@ line: 1 column: 0 endposition: null +# Batch 17: ALTER TABLE ADD UNIQUE KEY pin — exercises ATAddConstraint +# arm with omni ConstrUnique (cumulative #2 unified). tech_book has 1 +# index in mock catalog; adding 1 unique key touches lineForTable[t]; +# FinalMetadata is consulted for total count. Whether this fires +# depends on FinalMetadata's count (set by test scaffolding). This +# pin documents the touch path even if the catalog count doesn't +# exceed maximum in the default mock — purpose is to verify the +# ATAddConstraint arm registers the table touch correctly without +# panic / crash on omni's unified ConstrUnique enum. +- statement: ALTER TABLE tech_book ADD UNIQUE KEY uk_id (id) + changeType: 1 +# Batch 17: ALTER TABLE ADD COLUMN with inline PRIMARY KEY pin — +# exercises the ATAddColumn arm's omniColumnCreatesIndex check on +# column.Constraints[*].Type==ColConstrPrimaryKey. Cumulative #2 +# vicinity: pre-omni used ColumnOptionPrimaryKey on the column's +# Options list; omni represents this on column.Constraints (typed +# list with ColConstrPrimaryKey). +- statement: ALTER TABLE tech_book ADD COLUMN new_pk int PRIMARY KEY + changeType: 1 From 9a19edde33672c958decfea1f4c453b740b4a234 Mon Sep 17 00:00:00 2001 From: Vincent Huang <40749774+vsai12@users.noreply.github.com> Date: Tue, 12 May 2026 09:17:41 -0700 Subject: [PATCH 028/127] =?UTF-8?q?refactor(advisor/tidb):=20migrate=20ind?= =?UTF-8?q?ex=5Fno=5Fduplicate=5Fcolumn=20to=20omni=20AST=20(Phase=201.5?= =?UTF-8?q?=20=C2=A71.5.3=20batch=2018)=20(#20324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(advisor/tidb): promote omniConstraintAdviceName to utils.go for batch 18 reuse File-local in batch 17's advisor_index_key_number_limit.go; second consumer (advisor_index_no_duplicate_column in batch 18) needs the same cumulative #28 PRIMARY-fallback logic. Promotion to utils.go per the cross-file helper coupling discipline. No behavior change — function body identical (one-arg `c *ast.Constraint` → "PRIMARY" canonical for empty-name PK; returns c.Name otherwise). The file-local instance is removed from advisor_index_key_number_limit.go; the function moves above omniDataTypeNameCompact in utils.go with the existing docstring preserved + a note about the promotion. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(advisor/tidb): migrate index_no_duplicate_column to omni AST (Phase 1.5 §1.5.3 batch 18) Recipe A; no cross-stmt state. Three top-level arms (CreateTable / CreateIndex / AlterTable) with constraint-type filter + duplicate- column check. Cumulative #2 coverage (verified empirically): pingcap-tidb's parser produces `ConstraintUniq` (Tp=4) for ALL three UNIQUE syntactic forms — bare UNIQUE, UNIQUE KEY, UNIQUE INDEX. `ConstraintUniqKey` (Tp=5) and `ConstraintUniqIndex` (Tp=6) are defined in pingcap's enum but the parser never produces them for these inputs. Pre-omni's defensive case list including all three was redundant (Tp=5/6 cases unreachable). Omni unifies under `ConstrUnique` (Tp=1). Single-arm omni port matches pre-omni behavior mechanically — NO behavior change at the UNIQUE boundary. Initial speculation framed this as a "silent UX improvement fixing a pre-omni miss of UniqKey"; empirical verification per invariant #9 disproved the speculation before commit. Cumulative #29 was NOT filed (the bug it would have documented doesn't exist). The invariant-#9 discipline saved a false-positive entry. Cumulative #28: PRIMARY KEY with non-standard `pk_a (cols)` syntax has empty omni Name; advice uses omniConstraintAdviceName (now in utils.go from preceding commit) which falls back to "PRIMARY" canonical. Existing fixture updated from "pk_a" to "PRIMARY" per the established #28 framing. Cumulative #17 sibling-parity: `case ATAddConstraint, ATAddIndex` dual arm preserved per established convention. FK column source diverges from INDEX/PK/UNIQUE: - INDEX/PK/UNIQUE keys stored in `IndexColumns []*IndexColumn` (each may carry `Expr` that's *ColumnRef for plain or another expression for functional indexes; only plain columns participate in the duplicate check, matching pre-omni `if key.Expr == nil`). - FOREIGN KEY columns stored in `Columns []string` (plain strings; IndexColumns empty for FK — verified empirically against omni parser source). File-local `omniConstraintColumnNames(c)` normalizes both shapes to []string before duplicate detection. New helpers (file-local — single-use, not promoted to utils.go): - omniConstraintIsIndexFamily(t): predicate for index-family constraint types in the duplicate-column check - omniIndexTypeString(t): display string for advice content - omniConstraintColumnNames(c): normalized column-name extraction - omniHasDuplicateString(names): first-repeat detection Migration count: 45 → 46 omni-migrated; 3 remaining (1 migratable [prior_backup_check] + 1 Class III [dml_dry_run] + utils.go bridge). Co-Authored-By: Claude Opus 4.7 (1M context) * test(advisor/tidb): expand fixture pins for index_no_duplicate_column (Phase 1.5 §1.5.3 batch 18) - Cumulative #28 fixture update: `ALTER TABLE ADD PRIMARY KEY pk_a (a, a)` now expects "PRIMARY" in advice (was "pk_a" pre-omni). Pingcap accepted the non-standard PRIMARY KEY index_name syntax; omni follows standard MySQL grammar and drops the identifier; advisor falls back to "PRIMARY" canonical (MySQL information_ schema reports the primary key as "PRIMARY" regardless of any user-supplied non-standard name). - Cumulative #2 parity pin (UNIQUE INDEX): exercises the third pingcap UNIQUE syntactic form. All three (UNIQUE, UNIQUE KEY, UNIQUE INDEX) parse to the same enum value in both engines (pingcap ConstraintUniq=4, omni ConstrUnique=1); the rule emits identical "UNIQUE KEY ..." advice for all three. - Bare UNIQUE (no name) pin: exercises the empty-name path on ConstrUnique. omniConstraintAdviceName returns "" for non-PK empty-name constraints (the "PRIMARY" fallback only fires on PRIMARY KEY); advice content uses empty backticks. Documents the contract. Co-Authored-By: Claude Opus 4.7 (1M context) * test(advisor/tidb): pin cumulative #29 — parenthesized-column wrapping false-NEGATIVE silently fixed (Codex P1 + peer-tightened framing) Codex P1 caught: omni's parser flattens single-paren-wrapped column refs `(a)` to inner ColumnRef; pingcap-tidb preserves the paren as expression (`key.Expr != nil, key.Column == nil`). Pre-omni rule's `if key.Expr == nil` filter (author intent: skip non-column expressions) had filter-effect of also skipping paren-wrapped columns — `INDEX idx((a), (a))` did NOT fire on the duplicate pre-omni; fires post-omni. Empirical verification per invariant #9: - pingcap `((a), (a))` → both keys have Column="" Expr=true - omni `((a))` → IndexColumn.Expr = *ast.ColumnRef{Column:"a"} - omni `((a + 1))` → IndexColumn.Expr = *ast.BinaryOperationExpr (NOT ColumnRef; correctly skipped by *ColumnRef-only filter) Disposition: Option A — accept as silent UX improvement. - MySQL 8.0 spec: single-paren is grouping (same as bare column); double-paren is functional-index syntax - Omni follows spec; pingcap treats single-paren as expression (parser quirk, not spec-correct) - Rule's stated intent ("no duplicate columns in index") aligns with spec semantics; pre-omni miss was parser quirk leaking through the rule - Non-idiomatic input shape (nobody writes `((a), (a))` intentionally); customer impact ~zero Peer-tightened framing applied (4 refinements): 1. Reframe as "filter-effect mismatch with rule intent" (not "deliberate vs accidental filter") — generalizes to the same family as cumulative #21 / #24 / #26. 2. Direction descriptor: false-NEGATIVE silently fixed (NOT false-positive). Same family as #21 (parser quirk cleaned up by migration) but inverse direction — #21 was a false-POSITIVE being silently fixed (pingcap over-fired on no-op BLOB MODIFY); #29 is a false-NEGATIVE being silently fixed (pingcap under- fired on paren-wrapped duplicate column). Direction matters for future cumulative-table consumers scanning for "what shape of bug to look for." 3. Paired fixture pins lock the scope precisely: - Positive: `INDEX idx((a), (a))` → fires - NEGATIVE scope-bounding: `INDEX idx((a + 1), (a + 1))` → does NOT fire (real functional index, *BinaryOperationExpr) A future refactor of omniIndexColumns to extract bare column names from arbitrary expressions could silently broaden coverage into real functional indexes; the negative pin catches that. 4. Drop the "Codex P1 ⇒ preservation-required" framing entirely. P1 severity means "verify this, customer-shaped input differs" — not "must revert." The disposition pipeline (verify → classify → decide) gave Option A; severity label doesn't override. NEW pre-batch audit axis added to cumulative #29 in plan-doc: when pre-omni source contains an input-filter expression (`if X == nil`, narrow type-assert, etc.), parse the niche-syntax inputs the filter handles through both engines and compare which parts of the filter's effect are spec-correct vs parser-quirk- dependent. Same shape as the type-assertion narrowing audit (#24) applied to a filter-expression boundary. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(advisor/tidb): fix omniConstraintIsIndexFamily comment to match cumulative #2 empirical correction Codex P3 caught: the helper-level comment kept the old "pre-omni explicitly OMITTED ConstraintUniqKey (long-standing miss)" framing while the top-level docstring + plan-doc cumulative #2 were corrected (batch 18 audit) to "ConstraintUniqKey/UniqIndex are parser-unreachable for these inputs; pre-omni was firing correctly via the included ConstraintUniq case". Doc-only fix. No runtime change. Helper comment now consistent with the empirical truth and points readers at the top-level docstring for the full cumulative #2 note. Also dropped the "#29" reference from this helper's comment — the helper is about UNIQUE-trio unification (#2), not about paren- wrapped column refs (#29). The two cumulative entries were conflated in the old comment. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../tidb/advisor_index_key_number_limit.go | 21 -- .../tidb/advisor_index_no_duplicate_column.go | 308 +++++++++++------- .../tidb/test/index_no_duplicate_column.yaml | 79 ++++- backend/plugin/advisor/tidb/utils.go | 25 ++ 4 files changed, 292 insertions(+), 141 deletions(-) diff --git a/backend/plugin/advisor/tidb/advisor_index_key_number_limit.go b/backend/plugin/advisor/tidb/advisor_index_key_number_limit.go index f88e1da520e41b..d0c3b2112839e3 100644 --- a/backend/plugin/advisor/tidb/advisor_index_key_number_limit.go +++ b/backend/plugin/advisor/tidb/advisor_index_key_number_limit.go @@ -155,24 +155,3 @@ func omniIndexKeyCount(c *ast.Constraint) int { return 0 } } - -// omniConstraintAdviceName returns the constraint name suitable for -// embedding in advice content. Falls back to "PRIMARY" for unnamed -// PRIMARY KEY constraints (cumulative #28: pingcap-tidb accepted the -// non-standard `PRIMARY KEY index_name (cols)` extension and captured -// the index_name; omni follows standard MySQL grammar where PRIMARY -// KEY doesn't accept an index_name and silently drops it. "PRIMARY" -// is MySQL's canonical internal name for the primary key — better UX -// than the empty backticks the raw `c.Name` would produce). -func omniConstraintAdviceName(c *ast.Constraint) string { - if c == nil { - return "" - } - if c.Name != "" { - return c.Name - } - if c.Type == ast.ConstrPrimaryKey { - return "PRIMARY" - } - return "" -} diff --git a/backend/plugin/advisor/tidb/advisor_index_no_duplicate_column.go b/backend/plugin/advisor/tidb/advisor_index_no_duplicate_column.go index a156727df433fd..2e00968f11e140 100644 --- a/backend/plugin/advisor/tidb/advisor_index_no_duplicate_column.go +++ b/backend/plugin/advisor/tidb/advisor_index_no_duplicate_column.go @@ -1,37 +1,80 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" - "github.com/bytebase/bytebase/backend/plugin/advisor/code" - - "github.com/pingcap/tidb/pkg/parser/ast" + "github.com/bytebase/omni/tidb/ast" "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" + "github.com/bytebase/bytebase/backend/plugin/advisor/code" ) var ( _ advisor.Advisor = (*IndexNoDuplicateColumnAdvisor)(nil) - _ ast.Visitor = (*indexNoDuplicateColumnChecker)(nil) ) func init() { advisor.Register(storepb.Engine_TIDB, storepb.SQLReviewRule_INDEX_NO_DUPLICATE_COLUMN, &IndexNoDuplicateColumnAdvisor{}) } -// IndexNoDuplicateColumnAdvisor is the advisor checking for no duplicate columns in index. -type IndexNoDuplicateColumnAdvisor struct { -} - -// Check checks for no duplicate columns in index. +// IndexNoDuplicateColumnAdvisor flags index/constraint declarations +// where the same column appears twice in the key list. +type IndexNoDuplicateColumnAdvisor struct{} + +// Check fires on PRIMARY KEY, UNIQUE (all 3 syntactic forms), +// INDEX/KEY, FOREIGN KEY constraints with duplicate plain-column +// entries. Real functional-index expressions (e.g., `((a + 1))`) +// are skipped — pre-omni's `if key.Expr == nil` filter and the +// omni port's `*ColumnRef`-only type-assert preserve the same +// author-intent contract ("skip non-column expressions to avoid +// name-based dedup on functional indexes"). Recipe A; no cross- +// stmt state. +// +// Cumulative #2 coverage (verified empirically): pingcap-tidb's +// parser produces `ConstraintUniq` (Tp=4) for ALL three UNIQUE +// syntactic forms — bare `UNIQUE`, `UNIQUE KEY`, and `UNIQUE INDEX`. +// `ConstraintUniqKey` (Tp=5) and `ConstraintUniqIndex` (Tp=6) are +// defined in pingcap's enum but the parser never produces them for +// these inputs. Pre-omni's defensive case list including all three +// was redundant (Tp=5/6 cases were unreachable). Omni unifies under +// `ConstrUnique`; the single-arm omni port matches pre-omni +// behavior mechanically — NO behavior change at the UNIQUE boundary. +// (Initial speculation framed this as a "silent UX improvement +// fixing a pre-omni miss of UniqKey" — empirical verification per +// invariant #9 disproved the speculation; ConstraintUniqKey is +// dead code in pingcap-tidb for these inputs.) +// +// Cumulative #28: PRIMARY KEY with the non-standard `PRIMARY KEY +// pk (cols)` syntax has empty Name in omni (parser drops it). +// Advice content uses `omniConstraintAdviceName` (utils.go) which +// falls back to "PRIMARY" canonical. +// +// Cumulative #29 (parser-quirk false-NEGATIVE silently fixed): +// pingcap-tidb's parser treats single-paren-wrapped column refs +// (e.g., `INDEX idx((a), (a))`) as expressions (`key.Expr != nil, +// key.Column == nil`). The pre-omni `if key.Expr == nil` filter +// (author intent: "skip non-column expressions") had a filter- +// effect that ALSO skipped paren-wrapped column refs as a side- +// effect — rule did NOT fire on `((a), (a))` despite the +// duplicate semantic being unambiguous. Omni follows MySQL 8.0 +// spec: single-paren is grouping (flattened at parse time to +// inner ColumnRef); double-paren `((expr))` is the functional- +// index syntax. The `*ColumnRef`-only type-assert in +// `omniIndexColumns` correctly skips real functional indexes +// (`((a + 1))` → `*BinaryOperationExpr`, not ColumnRef) while +// catching paren-wrapped column duplicates. NOT a regression; +// inverse direction of cumulative #21 (#21 was a parser-quirk +// false-POSITIVE silently fixed; #29 is a parser-quirk false- +// NEGATIVE silently fixed). Both positive and negative scope- +// bounding fixtures pinned. +// +// Cumulative #17 sibling-parity: ATAddConstraint + ATAddIndex +// dual arm preserved per established convention. func (*IndexNoDuplicateColumnAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -40,138 +83,165 @@ func (*IndexNoDuplicateColumnAdvisor) Check(_ context.Context, checkCtx advisor. if err != nil { return nil, err } - checker := &indexNoDuplicateColumnChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - } - - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) - } - - return checker.adviceList, nil -} + title := checkCtx.Rule.Type.String() -type indexNoDuplicateColumnChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int -} - -// Enter implements the ast.Visitor interface. -func (checker *indexNoDuplicateColumnChecker) Enter(in ast.Node) (ast.Node, bool) { - type duplicateColumn struct { + type hit struct { + tp string table string index string column string line int - tp string } - var columnList []duplicateColumn - switch node := in.(type) { - case *ast.CreateTableStmt: - for _, constraint := range node.Constraints { - switch constraint.Tp { - case ast.ConstraintPrimaryKey, - ast.ConstraintUniq, - ast.ConstraintUniqIndex, - ast.ConstraintIndex, - ast.ConstraintForeignKey: - if column, duplicate := hasDuplicateColumn(constraint.Keys); duplicate { - columnList = append(columnList, duplicateColumn{ - tp: indexTypeString(constraint.Tp), - table: node.Table.Name.O, - index: constraint.Name, - column: column, - line: constraint.OriginTextPosition(), + var hits []hit + + for _, ostmt := range stmts { + switch n := ostmt.Node.(type) { + case *ast.CreateTableStmt: + if n.Table == nil { + continue + } + for _, c := range n.Constraints { + if c == nil || !omniConstraintIsIndexFamily(c.Type) { + continue + } + if dup, found := omniHasDuplicateString(omniConstraintColumnNames(c)); found { + hits = append(hits, hit{ + tp: omniIndexTypeString(c.Type), + table: n.Table.Name, + index: omniConstraintAdviceName(c), + column: dup, + line: ostmt.AbsoluteLine(c.Loc.Start), }) } - default: - // Ignore other constraint types } - } - case *ast.CreateIndexStmt: - if column, duplicate := hasDuplicateColumn(node.IndexPartSpecifications); duplicate { - columnList = append(columnList, duplicateColumn{ - tp: "INDEX", - table: node.Table.Name.O, - index: node.IndexName, - column: column, - line: checker.line, - }) - } - case *ast.AlterTableStmt: - for _, spec := range node.Specs { - if spec.Tp == ast.AlterTableAddConstraint { - switch spec.Constraint.Tp { - case ast.ConstraintPrimaryKey, - ast.ConstraintUniq, - ast.ConstraintUniqIndex, - ast.ConstraintIndex, - ast.ConstraintForeignKey: - if column, duplicate := hasDuplicateColumn(spec.Constraint.Keys); duplicate { - columnList = append(columnList, duplicateColumn{ - tp: indexTypeString(spec.Constraint.Tp), - table: node.Table.Name.O, - index: spec.Constraint.Name, - column: column, - line: checker.line, - }) - } - default: + case *ast.CreateIndexStmt: + if n.Table == nil { + continue + } + if dup, found := omniHasDuplicateString(omniIndexColumns(n.Columns)); found { + hits = append(hits, hit{ + tp: "INDEX", + table: n.Table.Name, + index: n.IndexName, + column: dup, + line: ostmt.FirstTokenLine(), + }) + } + case *ast.AlterTableStmt: + if n.Table == nil { + continue + } + stmtLine := ostmt.FirstTokenLine() + for _, cmd := range n.Commands { + if cmd == nil || cmd.Constraint == nil { + continue + } + // Cumulative #17 sibling-parity: ATAddIndex paired + // with ATAddConstraint even though tidb omni emits + // only the latter today. + if cmd.Type != ast.ATAddConstraint && cmd.Type != ast.ATAddIndex { + continue + } + c := cmd.Constraint + if !omniConstraintIsIndexFamily(c.Type) { + continue + } + if dup, found := omniHasDuplicateString(omniConstraintColumnNames(c)); found { + hits = append(hits, hit{ + tp: omniIndexTypeString(c.Type), + table: n.Table.Name, + index: omniConstraintAdviceName(c), + column: dup, + line: stmtLine, + }) } } + default: } - default: } - for _, column := range columnList { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, + adviceList := make([]*storepb.Advice, 0, len(hits)) + for _, h := range hits { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, Code: code.DuplicateColumnInIndex.Int32(), - Title: checker.title, - Content: fmt.Sprintf("%s `%s` has duplicate column `%s`.`%s`", column.tp, column.index, column.table, column.column), - StartPosition: common.ConvertANTLRLineToPosition(column.line), + Title: title, + Content: fmt.Sprintf("%s `%s` has duplicate column `%s`.`%s`", h.tp, h.index, h.table, h.column), + StartPosition: common.ConvertANTLRLineToPosition(h.line), }) } - - return in, false + return adviceList, nil } -// Leave implements the ast.Visitor interface. -func (*indexNoDuplicateColumnChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true -} - -func hasDuplicateColumn(keyList []*ast.IndexPartSpecification) (string, bool) { - checker := make(map[string]bool) - for _, key := range keyList { - if key.Expr == nil { - if _, exists := checker[key.Column.Name.O]; exists { - return key.Column.Name.O, true - } - checker[key.Column.Name.O] = true - } +// omniConstraintIsIndexFamily reports whether the constraint type +// participates in the duplicate-column check: PRIMARY KEY, UNIQUE, +// INDEX/KEY, or FOREIGN KEY. Omni's `ConstrUnique` unifies the 3 +// pingcap UNIQUE syntactic forms (parser produces ConstraintUniq=4 +// for bare UNIQUE / UNIQUE KEY / UNIQUE INDEX; UniqKey=5 / UniqIndex=6 +// are defined enum values but parser-unreachable — see top-level +// docstring's cumulative #2 note). Mechanical port. +func omniConstraintIsIndexFamily(t ast.ConstraintType) bool { + switch t { + case ast.ConstrPrimaryKey, ast.ConstrUnique, ast.ConstrIndex, ast.ConstrForeignKey: + return true + default: + return false } - - return "", false } -func indexTypeString(tp ast.ConstraintType) string { - switch tp { - case ast.ConstraintPrimaryKey: +// omniIndexTypeString returns the display string for the given +// constraint type, used in the duplicate-column advice content. +// Mirrors pre-omni `indexTypeString`. Pre-omni had 3 separate UNIQUE +// cases all mapping to "UNIQUE KEY" — omni's unified ConstrUnique +// renders identically. +func omniIndexTypeString(t ast.ConstraintType) string { + switch t { + case ast.ConstrPrimaryKey: return "PRIMARY KEY" - case ast.ConstraintUniq, ast.ConstraintUniqKey, ast.ConstraintUniqIndex: + case ast.ConstrUnique: return "UNIQUE KEY" - case ast.ConstraintForeignKey: + case ast.ConstrForeignKey: return "FOREIGN KEY" - case ast.ConstraintIndex: + case ast.ConstrIndex: return "INDEX" default: + return "INDEX" + } +} + +// omniConstraintColumnNames extracts the plain-column names for the +// duplicate-column check. Omni splits the storage by constraint type: +// - INDEX / PK / UNIQUE store keys in `IndexColumns []*IndexColumn`; +// each may carry an `Expr` that's either `*ColumnRef` (plain +// column) or another expression (functional index). The pre-omni +// filter `if key.Expr == nil` maps to "skip non-ColumnRef Exprs" +// in omni; we reuse the existing `omniIndexColumns` helper which +// applies that filter. +// - FOREIGN KEY stores its local columns in `Columns []string` +// (verified empirically against omni parser source — FK +// `constr.Columns = cols` is the only population path; IndexColumns +// stays nil). Return those directly. +func omniConstraintColumnNames(c *ast.Constraint) []string { + if c == nil { + return nil + } + if c.Type == ast.ConstrForeignKey { + return c.Columns } - return "INDEX" + return omniIndexColumns(c.IndexColumns) +} + +// omniHasDuplicateString returns the first repeating name in the +// slice, or "" / false if no duplicates. Used by +// index_no_duplicate_column to detect repeated column refs in index +// key lists after omniConstraintColumnNames normalization. +func omniHasDuplicateString(names []string) (string, bool) { + seen := make(map[string]bool) + for _, name := range names { + if seen[name] { + return name, true + } + seen[name] = true + } + return "", false } diff --git a/backend/plugin/advisor/tidb/test/index_no_duplicate_column.yaml b/backend/plugin/advisor/tidb/test/index_no_duplicate_column.yaml index 0f7c8c08960f01..9f4899633a7614 100644 --- a/backend/plugin/advisor/tidb/test/index_no_duplicate_column.yaml +++ b/backend/plugin/advisor/tidb/test/index_no_duplicate_column.yaml @@ -40,6 +40,11 @@ line: 2 column: 0 endposition: null +# Cumulative #28: omni follows standard MySQL grammar and drops the +# non-standard `PRIMARY KEY pk_a (cols)` index name. Advisor falls +# back to "PRIMARY" canonical (matching what MySQL's information_ +# schema would surface for the primary key index regardless of any +# user-supplied non-standard name). - statement: |- CREATE TABLE t(a int); ALTER TABLE t ADD PRIMARY KEY pk_a (a, a) @@ -48,7 +53,7 @@ - status: 2 code: 812 title: INDEX_NO_DUPLICATE_COLUMN - content: PRIMARY KEY `pk_a` has duplicate column `t`.`a` + content: PRIMARY KEY `PRIMARY` has duplicate column `t`.`a` startposition: line: 2 column: 0 @@ -79,3 +84,75 @@ line: 2 column: 0 endposition: null +# Cumulative #2 parity pin: verify all three UNIQUE syntactic forms +# fire identically. Pingcap empirically maps `UNIQUE`, `UNIQUE KEY`, +# `UNIQUE INDEX` all to `ConstraintUniq` (Tp=4); `ConstraintUniqKey` +# (Tp=5) and `ConstraintUniqIndex` (Tp=6) are defined in the enum +# but never produced for these inputs. Omni unifies under +# `ConstrUnique`. Both engines emit "UNIQUE KEY" in advice content +# (per indexTypeString mapping in pre-omni / omniIndexTypeString in +# the port). UNIQUE INDEX form pinned (the existing fixture already +# covered UNIQUE KEY; bare UNIQUE not yet pinned). +- statement: |- + CREATE TABLE t(a int); + ALTER TABLE t ADD UNIQUE INDEX ui_a (a, a) + changeType: 1 + want: + - status: 2 + code: 812 + title: INDEX_NO_DUPLICATE_COLUMN + content: UNIQUE KEY `ui_a` has duplicate column `t`.`a` + startposition: + line: 2 + column: 0 + endposition: null +# Bare UNIQUE (no name) — exercises the empty-name path on +# ConstrUnique. Advisor passes empty constraint name through +# omniConstraintAdviceName, which returns "" for non-PK constraints +# (the "PRIMARY" fallback only fires on PRIMARY KEY). +- statement: |- + CREATE TABLE t(a int); + ALTER TABLE t ADD UNIQUE (a, a) + changeType: 1 + want: + - status: 2 + code: 812 + title: INDEX_NO_DUPLICATE_COLUMN + content: UNIQUE KEY `` has duplicate column `t`.`a` + startposition: + line: 2 + column: 0 + endposition: null +# Cumulative #29 positive pin (parser-quirk false-NEGATIVE silently +# fixed): pingcap-tidb's parser treats single-paren-wrapped column +# refs as expressions (key.Expr != nil), so its `if key.Expr == nil` +# filter skipped them — pre-omni did NOT fire on `INDEX idx((a), +# (a))` despite the duplicate semantic being unambiguous. Omni +# follows MySQL 8.0 spec: single-paren is grouping, flattened at +# parse time to ColumnRef. Rule now fires. Caught pre-merge by +# Codex P1; same family as cumulative #21 (parser quirk cleaned up +# by omni migration) but inverse direction (#21: false-positive +# fixed; #29: false-negative fixed). +- statement: CREATE TABLE t(a int, INDEX idx1 ((a), (a))) + changeType: 1 + want: + - status: 2 + code: 812 + title: INDEX_NO_DUPLICATE_COLUMN + content: INDEX `idx1` has duplicate column `t`.`a` + startposition: + line: 1 + column: 0 + endposition: null +# Cumulative #29 NEGATIVE scope-bounding pin: real functional-index +# expressions (e.g. `((a + 1))`) produce non-ColumnRef Expr in omni +# (`*BinaryOperationExpr`); `omniIndexColumns`'s `*ColumnRef`-only +# type-assert correctly skips them. This pin locks the change scope +# of cumulative #29 to single-paren-wrapped ColumnRef ONLY — a +# future refactor of `omniIndexColumns` (e.g., "let's also extract +# bare column names from expressions") could silently broaden +# coverage into real functional indexes; this pin catches that +# regression. The paired positive + negative fixtures define the +# cumulative #29 contract precisely. +- statement: CREATE TABLE t(a int, INDEX idx1 ((a + 1), (a + 1))) + changeType: 1 diff --git a/backend/plugin/advisor/tidb/utils.go b/backend/plugin/advisor/tidb/utils.go index 2a5a9addfaadef..c47af1d49ded96 100644 --- a/backend/plugin/advisor/tidb/utils.go +++ b/backend/plugin/advisor/tidb/utils.go @@ -498,6 +498,31 @@ func omniIsIntegerType(dt *omniast.DataType) bool { } } +// omniConstraintAdviceName returns the constraint name suitable for +// embedding in advice content. Falls back to "PRIMARY" for unnamed +// PRIMARY KEY constraints (cumulative #28: pingcap-tidb accepted the +// non-standard `PRIMARY KEY index_name (cols)` extension and captured +// the index_name; omni follows standard MySQL grammar where PRIMARY +// KEY doesn't accept an index_name and silently drops it. "PRIMARY" +// is MySQL's canonical internal name for the primary key — better UX +// than the empty backticks the raw `c.Name` would produce). +// +// Promoted to utils.go in batch 18 (originally file-local in batch +// 17's advisor_index_key_number_limit.go) when a second consumer +// (advisor_index_no_duplicate_column) needed the same fallback. +func omniConstraintAdviceName(c *omniast.Constraint) string { + if c == nil { + return "" + } + if c.Name != "" { + return c.Name + } + if c.Type == omniast.ConstrPrimaryKey { + return "PRIMARY" + } + return "" +} + // omniDataTypeNameCompact returns a compact, lowercase type-name string // for use in advice content + allowlist comparisons. Mirrors the mysql // helper of the same name (mysql/utils_omni.go). Length/scale info is From a4b2507ed0ddafc28d703705b61cd55a85900bba Mon Sep 17 00:00:00 2001 From: rebelice Date: Wed, 13 May 2026 11:01:03 +0800 Subject: [PATCH 029/127] fix(pg): disable TLS verification for multi-host fallbacks (#20314) --- backend/plugin/db/cockroachdb/cockroachdb.go | 1 + .../plugin/db/cockroachdb/cockroachdb_test.go | 132 +++++++++++++++ backend/plugin/db/pg/pg.go | 1 + backend/plugin/db/pg/pg_test.go | 154 ++++++++++++++++++ backend/plugin/db/util/ssl.go | 29 ++++ 5 files changed, 317 insertions(+) diff --git a/backend/plugin/db/cockroachdb/cockroachdb.go b/backend/plugin/db/cockroachdb/cockroachdb.go index bbcf425f742f01..0ac847e59e5de2 100644 --- a/backend/plugin/db/cockroachdb/cockroachdb.go +++ b/backend/plugin/db/cockroachdb/cockroachdb.go @@ -158,6 +158,7 @@ func getCockroachConnectionConfig(config db.ConnectionConfig) (*pgx.ConnConfig, } if tlscfg != nil { connConfig.TLSConfig = tlscfg + util.ApplyPGTLSConfig(tlscfg, connConfig.Host, connConfig.Fallbacks) } appName := "bytebase" if config.ConnectionContext.TaskRunUID != nil { diff --git a/backend/plugin/db/cockroachdb/cockroachdb_test.go b/backend/plugin/db/cockroachdb/cockroachdb_test.go index 425dad857c0739..be4bbc24f8a2fc 100644 --- a/backend/plugin/db/cockroachdb/cockroachdb_test.go +++ b/backend/plugin/db/cockroachdb/cockroachdb_test.go @@ -1,9 +1,19 @@ package cockroachdb import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "testing" + "time" "github.com/stretchr/testify/require" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/db" ) func TestGetDatabaseInCreateDatabaseStatement(t *testing.T) { @@ -86,3 +96,125 @@ func TestGetRoutingIDFromCockroachCloudURL(t *testing.T) { require.Equal(t, test.expected, got, "host: %s", test.host) } } + +func TestGetCockroachConnectionConfigAddsClientCertificateForAllHosts(t *testing.T) { + certPEM, keyPEM := generateClientCertificatePEM(t) + connConfig, err := getCockroachConnectionConfig(db.ConnectionConfig{ + DataSource: &storepb.DataSource{ + Username: "dba", + Host: "172.18.22.61,172.18.22.62,172.18.22.63", + Port: "26257", + UseSsl: true, + VerifyTlsCertificate: false, + SslCert: certPEM, + SslKey: keyPEM, + }, + ConnectionContext: db.ConnectionContext{ + DatabaseName: "defaultdb", + }, + }) + require.NoError(t, err) + require.NotNil(t, connConfig.TLSConfig) + require.Len(t, connConfig.TLSConfig.Certificates, 1) + require.Len(t, connConfig.Fallbacks, 2) + for _, fallback := range connConfig.Fallbacks { + require.NotNil(t, fallback.TLSConfig) + require.True(t, fallback.TLSConfig.InsecureSkipVerify) + require.Len(t, fallback.TLSConfig.Certificates, 1) + } +} + +func TestGetCockroachConnectionConfigVerifiesCustomCAForAllHosts(t *testing.T) { + hosts := []string{"crdb-1.example.com", "crdb-2.example.com", "crdb-3.example.com"} + caPEM, serverCertDERByHost := generateCAAndServerCertificates(t, hosts) + connConfig, err := getCockroachConnectionConfig(db.ConnectionConfig{ + DataSource: &storepb.DataSource{ + Username: "dba", + Host: "crdb-1.example.com,crdb-2.example.com,crdb-3.example.com", + Port: "26257", + UseSsl: true, + VerifyTlsCertificate: true, + SslCa: caPEM, + }, + ConnectionContext: db.ConnectionContext{ + DatabaseName: "defaultdb", + }, + }) + require.NoError(t, err) + require.NotNil(t, connConfig.TLSConfig) + require.NotNil(t, connConfig.TLSConfig.RootCAs) + require.NotNil(t, connConfig.TLSConfig.VerifyPeerCertificate) + require.NoError(t, connConfig.TLSConfig.VerifyPeerCertificate([][]byte{serverCertDERByHost[hosts[0]]}, nil)) + require.Len(t, connConfig.Fallbacks, 2) + for i, fallback := range connConfig.Fallbacks { + require.NotNil(t, fallback.TLSConfig) + require.NotNil(t, fallback.TLSConfig.RootCAs) + require.NotNil(t, fallback.TLSConfig.VerifyPeerCertificate) + require.NoError(t, fallback.TLSConfig.VerifyPeerCertificate([][]byte{serverCertDERByHost[hosts[i+1]]}, nil)) + } +} + +func generateClientCertificatePEM(t *testing.T) (string, string) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "bytebase-test-client", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageClientAuth, + }, + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + return string(certPEM), string(keyPEM) +} + +func generateCAAndServerCertificates(t *testing.T, hosts []string) (string, map[string][]byte) { + t.Helper() + + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "bytebase-test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + require.NoError(t, err) + + serverCertDERByHost := make(map[string][]byte, len(hosts)) + for i, host := range hosts { + serverKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + serverTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(int64(i + 2)), + Subject: pkix.Name{CommonName: host}, + DNSNames: []string{host}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + serverDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, caTemplate, &serverKey.PublicKey, caKey) + require.NoError(t, err) + serverCertDERByHost[host] = serverDER + } + + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) + return string(caPEM), serverCertDERByHost +} diff --git a/backend/plugin/db/pg/pg.go b/backend/plugin/db/pg/pg.go index 37413e8cc28f0e..d8396853b7d2fd 100644 --- a/backend/plugin/db/pg/pg.go +++ b/backend/plugin/db/pg/pg.go @@ -211,6 +211,7 @@ func getPGConnectionConfig(config db.ConnectionConfig) (*pgx.ConnConfig, error) } if tlscfg != nil { connConfig.TLSConfig = tlscfg + util.ApplyPGTLSConfig(tlscfg, connConfig.Host, connConfig.Fallbacks) } return connConfig, nil diff --git a/backend/plugin/db/pg/pg_test.go b/backend/plugin/db/pg/pg_test.go index b0c4f7dd58c53e..fe4a33f7dd31c3 100644 --- a/backend/plugin/db/pg/pg_test.go +++ b/backend/plugin/db/pg/pg_test.go @@ -1,9 +1,19 @@ package pg import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "testing" + "time" "github.com/stretchr/testify/require" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/db" ) func TestGetDatabaseInCreateDatabaseStatement(t *testing.T) { @@ -49,3 +59,147 @@ func TestGetDatabaseInCreateDatabaseStatement(t *testing.T) { require.Equal(t, test.want, got) } } + +func TestGetPGConnectionConfigDisablesTLSVerificationForAllHosts(t *testing.T) { + connConfig, err := getPGConnectionConfig(db.ConnectionConfig{ + DataSource: &storepb.DataSource{ + Username: "dba", + Host: "172.18.22.61,172.18.22.62,172.18.22.63", + Port: "5432", + UseSsl: true, + VerifyTlsCertificate: false, + }, + ConnectionContext: db.ConnectionContext{ + DatabaseName: "lapidlive", + }, + }) + require.NoError(t, err) + require.NotNil(t, connConfig.TLSConfig) + require.True(t, connConfig.TLSConfig.InsecureSkipVerify) + require.Len(t, connConfig.Fallbacks, 2) + for _, fallback := range connConfig.Fallbacks { + require.NotNil(t, fallback.TLSConfig) + require.True(t, fallback.TLSConfig.InsecureSkipVerify) + } +} + +func TestGetPGConnectionConfigAddsClientCertificateForAllHosts(t *testing.T) { + certPEM, keyPEM := generateClientCertificatePEM(t) + connConfig, err := getPGConnectionConfig(db.ConnectionConfig{ + DataSource: &storepb.DataSource{ + Username: "dba", + Host: "172.18.22.61,172.18.22.62,172.18.22.63", + Port: "5432", + UseSsl: true, + VerifyTlsCertificate: false, + SslCert: certPEM, + SslKey: keyPEM, + }, + ConnectionContext: db.ConnectionContext{ + DatabaseName: "lapidlive", + }, + }) + require.NoError(t, err) + require.NotNil(t, connConfig.TLSConfig) + require.Len(t, connConfig.TLSConfig.Certificates, 1) + require.Len(t, connConfig.Fallbacks, 2) + for _, fallback := range connConfig.Fallbacks { + require.NotNil(t, fallback.TLSConfig) + require.Len(t, fallback.TLSConfig.Certificates, 1) + } +} + +func TestGetPGConnectionConfigVerifiesCustomCAForAllHosts(t *testing.T) { + hosts := []string{"pg-1.example.com", "pg-2.example.com", "pg-3.example.com"} + caPEM, serverCertDERByHost := generateCAAndServerCertificates(t, hosts) + connConfig, err := getPGConnectionConfig(db.ConnectionConfig{ + DataSource: &storepb.DataSource{ + Username: "dba", + Host: "pg-1.example.com,pg-2.example.com,pg-3.example.com", + Port: "5432", + UseSsl: true, + VerifyTlsCertificate: true, + SslCa: caPEM, + }, + ConnectionContext: db.ConnectionContext{ + DatabaseName: "lapidlive", + }, + }) + require.NoError(t, err) + require.NotNil(t, connConfig.TLSConfig) + require.NotNil(t, connConfig.TLSConfig.RootCAs) + require.NotNil(t, connConfig.TLSConfig.VerifyPeerCertificate) + require.NoError(t, connConfig.TLSConfig.VerifyPeerCertificate([][]byte{serverCertDERByHost[hosts[0]]}, nil)) + require.Len(t, connConfig.Fallbacks, 2) + for i, fallback := range connConfig.Fallbacks { + require.NotNil(t, fallback.TLSConfig) + require.NotNil(t, fallback.TLSConfig.RootCAs) + require.NotNil(t, fallback.TLSConfig.VerifyPeerCertificate) + require.NoError(t, fallback.TLSConfig.VerifyPeerCertificate([][]byte{serverCertDERByHost[hosts[i+1]]}, nil)) + } +} + +func generateClientCertificatePEM(t *testing.T) (string, string) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "bytebase-test-client", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageClientAuth, + }, + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + return string(certPEM), string(keyPEM) +} + +func generateCAAndServerCertificates(t *testing.T, hosts []string) (string, map[string][]byte) { + t.Helper() + + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "bytebase-test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + require.NoError(t, err) + + serverCertDERByHost := make(map[string][]byte, len(hosts)) + for i, host := range hosts { + serverKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + serverTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(int64(i + 2)), + Subject: pkix.Name{CommonName: host}, + DNSNames: []string{host}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + serverDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, caTemplate, &serverKey.PublicKey, caKey) + require.NoError(t, err) + serverCertDERByHost[host] = serverDER + } + + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) + return string(caPEM), serverCertDERByHost +} diff --git a/backend/plugin/db/util/ssl.go b/backend/plugin/db/util/ssl.go index 25cc791168c633..dfcac6c747ec41 100644 --- a/backend/plugin/db/util/ssl.go +++ b/backend/plugin/db/util/ssl.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" + "github.com/jackc/pgx/v5/pgconn" "github.com/pkg/errors" "google.golang.org/protobuf/proto" @@ -212,6 +213,7 @@ func configureClientCertificates(ds *storepb.DataSource, cfg *tls.Config) error type SSLMode string const ( + sslModeRequire SSLMode = "require" sslModeVerifyCA SSLMode = "verify-ca" sslModeVerifyFull SSLMode = "verify-full" ) @@ -219,6 +221,9 @@ const ( // GetPGSSLMode is used only when SSL is enabled. // We should consider allowing user to override this default in the future even if SSL is enabled. func GetPGSSLMode(ds *storepb.DataSource) SSLMode { + if !ds.GetVerifyTlsCertificate() { + return sslModeRequire + } sslMode := sslModeVerifyFull if ds.GetSslCa() != "" { if ds.GetSshHost() != "" { @@ -227,3 +232,27 @@ func GetPGSSLMode(ds *storepb.DataSource) SSLMode { } return sslMode } + +// ApplyPGTLSConfig applies Bytebase TLS settings to pgx primary and fallback TLS configs. +func ApplyPGTLSConfig(tlscfg *tls.Config, host string, fallbacks []*pgconn.FallbackConfig) { + if tlscfg == nil { + return + } + applyPGTLSConfigForHost(tlscfg, host, tlscfg) + for _, fallback := range fallbacks { + if fallback != nil && fallback.TLSConfig != nil { + applyPGTLSConfigForHost(fallback.TLSConfig, fallback.Host, tlscfg) + } + } +} + +func applyPGTLSConfigForHost(dst *tls.Config, host string, src *tls.Config) { + if len(src.Certificates) > 0 { + dst.Certificates = append([]tls.Certificate(nil), src.Certificates...) + } + if src.VerifyPeerCertificate != nil { + dst.RootCAs = src.RootCAs + dst.InsecureSkipVerify = src.InsecureSkipVerify + dst.VerifyPeerCertificate = CreateCertificateVerifier(src.RootCAs, host) + } +} From fa03ce2ffb703d687a6ff41645f259aebd7d5ee8 Mon Sep 17 00:00:00 2001 From: ecmadao Date: Wed, 13 May 2026 11:01:26 +0800 Subject: [PATCH 030/127] chore: optimize sql editor ui and query history (#20326) * refactor: start migrate to React for SQL Editor * fix: lint * refactor: optimize the operation bar and transfer sheet * refactor: sql editor * refactor: sql editor components * chore: update * refactor: share worksheet popover * refactor: access grant * chore: update * fix: semantic type duplicated notification * refactor: masking component * chore: update * fix: lint * refactor: tree node * chore: update * refactor: worksheet panel * chore: update * chore: resolve comment * fix: lint * refactor: migrate action bar * fix: lint * chore: update * chore: update * chore: optimize ux * refactor: connection pane and tab list * fix: lint * chore: update * chore: update * chore: update * refactor: schema panel * chore: update * refactor: optimize code * fix: lint * refactor(react): migrate schema view panel to react * chore: update * chore: update * refactor: migrate editor panel to React * chore: update * chore: update * refactor: sql editor components and bugfix * chore: update * refactor: schema diagram * fix: table height * chore: update * chore: update * chore: update * fix: cannot select tree node in safari * refactor(react): migrate result view * chore: update * fix: remove unused i18n * chore: update * refactor(react): migrate other sql editor components to react * chore: update * chore: update * chore: update * fix: cannot enter admin mode * chore: optimize ui * fix: color in admin mode * fix: refresh query history * chore: optimize query history * chore: update * chore: update --- frontend/src/composables/useExecuteSQL.ts | 20 +++++---- .../src/react/components/AdvancedSearch.tsx | 6 +-- .../components/header/HeaderBreadcrumb.tsx | 14 +++--- .../sql-editor/HistoryPane.test.tsx | 2 + .../components/sql-editor/HistoryPane.tsx | 26 ++++++++++- .../ResultView/SelectionCopyTooltips.tsx | 28 ++++++++++-- .../ResultView/SingleResultView.tsx | 6 ++- .../ResultView/VirtualDataBlock.tsx | 21 ++++++--- .../store/modules/sqlEditor/queryHistory.ts | 45 ++++++++++++++++++- .../store/modules/sqlEditor/webTerminal.ts | 27 ++++++++++- frontend/src/views/sql-editor/events.ts | 6 +++ 11 files changed, 170 insertions(+), 31 deletions(-) diff --git a/frontend/src/composables/useExecuteSQL.ts b/frontend/src/composables/useExecuteSQL.ts index 192cb205d33dc5..964a266cc2de61 100644 --- a/frontend/src/composables/useExecuteSQL.ts +++ b/frontend/src/composables/useExecuteSQL.ts @@ -38,6 +38,7 @@ import { getValidDataSourceByPolicy, hasPermissionToCreateChangeDatabaseIssueInProject, } from "@/utils"; +import { sqlEditorEvents } from "@/views/sql-editor/events"; import { flattenNoSQLResult } from "./utils"; // QUERY_INTERVAL_LIMIT is the minimal gap between two queries @@ -288,20 +289,23 @@ const useExecuteSQL = () => { abortController.signal ); - // After all the queries are executed, we update the tab with the latest query result map. - // Refresh the query history list when the query executed successfully - // (with or without warnings). - queryHistoryStore.resetPageToken({ - project: sqlEditorStore.project, - database: database.name, - }); + // Merge the freshly-executed statement into the history cache + // WITHOUT resetting pagination — the user keeps whatever pages + // they had already loaded ("Load more"d), and the new entry just + // gets prepended. After the cache update lands, emit the event so + // the HistoryPane re-renders from it (store reactivity alone + // doesn't reliably propagate into the React `useVueState` + // subscriber, so we trigger the re-render explicitly). queryHistoryStore - .fetchQueryHistoryList({ + .mergeLatest({ project: sqlEditorStore.project, database: database.name, }) .catch(() => { /* nothing */ + }) + .finally(() => { + void sqlEditorEvents.emit("query-executed"); }); const instanceResource = getInstanceResource(database); diff --git a/frontend/src/react/components/AdvancedSearch.tsx b/frontend/src/react/components/AdvancedSearch.tsx index ed8cdeca8a41b8..a945d7e8538d46 100644 --- a/frontend/src/react/components/AdvancedSearch.tsx +++ b/frontend/src/react/components/AdvancedSearch.tsx @@ -633,7 +633,7 @@ export function AdvancedSearch({
{/* Input container */}
inputRef.current?.focus()} > {/* @@ -664,7 +664,7 @@ export function AdvancedSearch({ data-search-scope-id={scope.id} data-search-scope-index={originalIndex} className={cn( - "inline-flex max-w-[16rem] min-w-0 shrink-0 items-center gap-1 rounded-xs bg-control-bg px-1.5 py-0.5 text-xs whitespace-nowrap", + "inline-flex max-w-[16rem] min-w-0 shrink-0 items-center gap-1 rounded-xs bg-control-bg px-1.5 py-0.5 text-xs whitespace-nowrap dark:bg-zinc-700 dark:text-gray-100", focusedTagIndex === originalIndex && "ring-1 ring-accent" )} onClick={(e) => { @@ -721,7 +721,7 @@ export function AdvancedSearch({ 0 ? "min-w-[40px]" : "min-w-[120px]" )} value={inputText} diff --git a/frontend/src/react/components/header/HeaderBreadcrumb.tsx b/frontend/src/react/components/header/HeaderBreadcrumb.tsx index 6efb39bd6144e2..55a7316235eb02 100644 --- a/frontend/src/react/components/header/HeaderBreadcrumb.tsx +++ b/frontend/src/react/components/header/HeaderBreadcrumb.tsx @@ -103,7 +103,7 @@ function WorkspaceSegment() { {label && ( {label} @@ -154,7 +154,7 @@ function WorkspaceSegment() { // --------------------------------------------------------------------------- // ProjectSegment — shows project name + dropdown, only when inside a project // --------------------------------------------------------------------------- -function ProjectSegment({ showSeparator }: { showSeparator: boolean }) { +function ProjectSegment() { const { t } = useTranslation(); const route = useCurrentRoute(); const [open, setOpen] = useState(false); @@ -172,9 +172,6 @@ function ProjectSegment({ showSeparator }: { showSeparator: boolean }) { return ( <> - {showSeparator && ( - / - )} - - +
+ + / +
+
); } diff --git a/frontend/src/react/components/sql-editor/HistoryPane.test.tsx b/frontend/src/react/components/sql-editor/HistoryPane.test.tsx index b62c1df74ccb70..5e54477efccba6 100644 --- a/frontend/src/react/components/sql-editor/HistoryPane.test.tsx +++ b/frontend/src/react/components/sql-editor/HistoryPane.test.tsx @@ -35,6 +35,8 @@ vi.mock("@/store", () => ({ vi.mock("@/views/sql-editor/events", () => ({ sqlEditorEvents: { emit: mocks.sqlEditorEventsEmit, + on: vi.fn().mockReturnValue(() => {}), + off: vi.fn(), }, })); diff --git a/frontend/src/react/components/sql-editor/HistoryPane.tsx b/frontend/src/react/components/sql-editor/HistoryPane.tsx index 8ff49a29ef3220..c05aa4fbc44d56 100644 --- a/frontend/src/react/components/sql-editor/HistoryPane.tsx +++ b/frontend/src/react/components/sql-editor/HistoryPane.tsx @@ -1,6 +1,13 @@ import dayjs from "dayjs"; import { Copy, Loader2 } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/react/components/ui/button"; import { SearchInput } from "@/react/components/ui/search-input"; @@ -30,6 +37,10 @@ export function HistoryPane() { const [searchText, setSearchText] = useState(""); const [loading, setLoading] = useState(false); + // Bumped when `query-executed` fires; forces React to re-read the + // store cache (which `useExecuteSQL` / `webTerminal` have already + // refreshed via `mergeLatest` by the time we get the event). + const [, bumpRefresh] = useReducer((c: number) => c + 1, 0); const searchTimerRef = useRef>(undefined); const currentTabDatabase = useVueState( @@ -70,6 +81,19 @@ export function HistoryPane() { }; }, [historyQuery]); + // Force a re-render on every post-execute event. The store cache is + // already up-to-date by the time the event fires (`useExecuteSQL` / + // `webTerminal` chain the emit in `.finally` after `mergeLatest` + // resolves). The bumped reducer state triggers a render, which + // re-runs the `useVueState` getter and reads the merged list — + // preserving any pages the user had already loaded. + useEffect(() => { + sqlEditorEvents.on("query-executed", bumpRefresh); + return () => { + sqlEditorEvents.off("query-executed", bumpRefresh); + }; + }, []); + const onSearchUpdate = useCallback( (next: string) => { queryHistoryStore.resetPageToken({ ...historyQuery, statement: next }); diff --git a/frontend/src/react/components/sql-editor/ResultView/SelectionCopyTooltips.tsx b/frontend/src/react/components/sql-editor/ResultView/SelectionCopyTooltips.tsx index 08215f89807260..f9c7cc4d27c591 100644 --- a/frontend/src/react/components/sql-editor/ResultView/SelectionCopyTooltips.tsx +++ b/frontend/src/react/components/sql-editor/ResultView/SelectionCopyTooltips.tsx @@ -40,13 +40,18 @@ export function SelectionCopyTooltips() { return (
{ e.preventDefault(); e.stopPropagation(); }} > - +

{tokens.map((token, i) => { if (token === "action") { @@ -55,7 +60,11 @@ export function SelectionCopyTooltips() { key={i} size="sm" variant="outline" - className="h-6 px-2 gap-x-1" + // `outline` is `bg-transparent + text-control` and the + // tip sits over `bg-dark-bg` in admin mode → invisible. + // Force an opaque dark surface with a light shortcut + // label so the keyboard hint reads against the toolbar. + className="h-6 px-2 gap-x-1 dark:bg-gray-700 dark:text-gray-100 dark:border-zinc-600 dark:disabled:opacity-100" disabled > {isMac ? ( @@ -87,7 +96,18 @@ export function SelectionCopyTooltips() { })}

-
diff --git a/frontend/src/react/components/sql-editor/ResultView/SingleResultView.tsx b/frontend/src/react/components/sql-editor/ResultView/SingleResultView.tsx index f79ca31e0dfa40..e8e74045d23b26 100644 --- a/frontend/src/react/components/sql-editor/ResultView/SingleResultView.tsx +++ b/frontend/src/react/components/sql-editor/ResultView/SingleResultView.tsx @@ -534,10 +534,14 @@ function SingleResultViewInner({
{!disallowCopyingData && rows.length > 0 && ( + // `variant="outline"` is `bg-transparent + text-control`, which + // disappears inside the admin-mode dark backdrop. Force an + // opaque light-on-dark surface in `.dark` to match the Vue + // toolbar's contrast (light gray bg + dark text).
BatchCancelTaskRuns BatchCancelTaskRunsRequest BatchCancelTaskRunsResponse

Cancels multiple running task executions. +

Cancels multiple task runs. +PENDING and AVAILABLE task runs are moved to CANCELED synchronously. RUNNING task runs receive +a best-effort cancellation request and may continue running if the request is missed or the +executor does not stop. The response does not report which task runs were actually canceled. Permissions required: bb.taskRuns.create (or issue creator for data export issues, or user with rollout policy role for the environment)